gowalk-cicd 1.0.95 → 1.0.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -155,7 +155,7 @@ node /path/to/gowalk-cicd/bin/cli.mjs
155
155
  Unknown code or origins refuse; SDK setup, non-cache children and later steps retain their
156
156
  proxy requirement. Never export this scope through `GITHUB_ENV` or add cache domains to
157
157
  `NO_PROXY`. The preloader snapshot under `RUNNER_TEMP` also covers official post/save after
158
- consumer autoupdate changes the action directory. The installer delivers this development
158
+ older consumer autoupdate changes the action directory. The installer delivers this development
159
159
  tooling for native iOS repositories too; it does not require an Android app.
160
160
  - Flutter manifest/archive GET recovery is scoped to the two pinned setup invocations through `BASH_ENV`.
161
161
  It retains the account proxy and allows four attempts within 60 seconds for manifests or 20 minutes
@@ -165,6 +165,10 @@ node /path/to/gowalk-cicd/bin/cli.mjs
165
165
  `flutter_download_failed` emits fixed transport codes and retained-byte counts, never curl stderr.
166
166
  Archive progress emits retained byte counts every minute and safe results after each attempt;
167
167
  the transfer deadline leaves room for a failure receipt before a 30-minute validation job ends.
168
+ With SDK caching enabled, failed archive transfers preserve public bytes in a job-owned,
169
+ digest-scoped directory. Separate optional cache restore/save steps retain them between runs;
170
+ they never expose partial bytes as an installed SDK. Successful or corrupt completed archives
171
+ are removed from that partial cache. Full SDK cache identities and provider transport stay unchanged.
168
172
  - Web Release selects bundled renderer resources with `--no-web-resources-cdn` inside
169
173
  the existing proxied build command. This is not a font or integration offline guarantee;
170
174
  preserve app-authored font declarations, bootstrap configuration and other build inputs.
@@ -309,7 +313,10 @@ node /path/to/gowalk-cicd/bin/cli.mjs
309
313
  from `${{ github.action_path }}` after that step. The plugin self-update
310
314
  (`autoupdate_check.sh`) runs right after the snapshot and preserves its own source candidate,
311
315
  before the version/signing/archive steps, so a red deploy still receives the
312
- release that fixes it; the snapshot is what keeps that run on one version.
316
+ release that fixes it. `autoupdate_stage.py` installs and preserves that update in one
317
+ task-owned detached worktree, then removes it. The active build's HEAD, index, dirty files
318
+ and action manifests stay unchanged: GitHub reparses local composites for post hooks using
319
+ cached step IDs, so a script snapshot alone cannot protect a changed manifest's step count.
313
320
  `test_action_step_order.py` pins both. A new script reference in
314
321
  `action/action.yml` therefore uses `"$SWIFT_APP_ACTION/scripts/..."`.
315
322
  - Delivery dependencies may remain after a green job; each is a typed
package/README.md CHANGED
@@ -321,6 +321,11 @@ and `retained_bytes`; `attempt_finished` adds `attempt`, `curl_exit`, `http_stat
321
321
  and `proxy_status`. In-progress notices arrive each minute; elapsed time belongs to
322
322
  the current attempt. Archives have a 20-minute total transfer budget so a failure
323
323
  receipt precedes a 30-minute validation job's deadline. Progress is not a success receipt.
324
+ When SDK caching is enabled, an exhausted transfer preserves its partial public archive for
325
+ the next run under a separate release-specific cache key. Resumption still uses the selected
326
+ official manifest digest and the configured provider proxy; only a verified complete archive
327
+ can reach SDK extraction. Partial-cache restore uses the existing one-minute cache segment
328
+ bound. Optional cache operations do not extend the transfer deadline or turn a failed installation green.
324
329
 
325
330
  Add a field by bumping the schema version; never change the meaning of an
326
331
  existing one.
@@ -335,8 +340,10 @@ into its current task branch and land it through the normal checked PR, includin
335
340
  when later deploy steps fail. CI never pushes to the default branch or rebases its
336
341
  dirty build checkout; a failed preservation is a job failure. The run that
337
342
  performed the update keeps executing the version it started with: the action
338
- snapshots itself to `$RUNNER_TEMP` before anything mutates and reads every script
339
- and prompt from that copy.
343
+ snapshots its scripts to `$RUNNER_TEMP`. Updates install and publish from a separate,
344
+ owned detached worktree that is removed afterward. The active checkout's action
345
+ manifests remain unchanged too: GitHub reloads local composites for post hooks using
346
+ previously cached step IDs, so changing their step count can crash cleanup.
340
347
 
341
348
  ### Google Play upload retry
342
349
 
@@ -1011,7 +1018,7 @@ The vendored action ships with a per-run autoupdate check. On every
1011
1018
  push to your default branch, the action queries npm for the latest
1012
1019
  `gowalk-cicd`, compares against the local marker at
1013
1020
  `.github/actions/swift-app/.daemux-version`, and if newer, re-vendors
1014
- the package via `npx --yes` and preserves the refreshed action files
1021
+ the package via `npx --yes` in a temporary detached worktree and preserves its action files
1015
1022
  on a verified task branch (under both `.github/actions/swift-app/` and
1016
1023
  `.github/actions/android-app/`).
1017
1024
  `.github/workflows/deploy.yml` is NEVER auto-committed — see
@@ -1 +1 @@
1
- 1.0.95
1
+ 1.0.97
package/action/action.yml CHANGED
@@ -179,7 +179,7 @@ inputs:
179
179
  description: >
180
180
  Per-run check for a newer gowalk-cicd on npm. When 'true'
181
181
  (default), the action queries npm on default-branch push runs and
182
- re-vendors the package via `npx --yes` if the published version is
182
+ installs the package in a detached worktree via `npx --yes` if its version is
183
183
  newer than the marker at .github/actions/swift-app/.daemux-version.
184
184
  The refreshed action files (NOT deploy.yml, which GITHUB_TOKEN
185
185
  cannot push) are retained on a verified task branch for the app session's
@@ -190,14 +190,9 @@ inputs:
190
190
  runs:
191
191
  using: composite
192
192
  steps:
193
- # Every later step reads this action's scripts and prompts from the
194
- # snapshot, never from ${action_path}. The autoupdate below re-vendors
195
- # .github/actions/swift-app/ — the same directory as ${action_path} for
196
- # local-action consumers — while this run is still executing the
197
- # action.yml the runner loaded at job start. Reading scripts from the
198
- # refreshed tree under the old step definitions is a mid-run version
199
- # mix; one copy taken before anything mutates keeps the whole run on
200
- # one version of the plugin.
193
+ # Keep every script and prompt on the version this invocation started with.
194
+ # Auto-update installs and publishes from a detached worktree; it never changes
195
+ # action manifests that the runner may reparse using already-cached step IDs.
201
196
  - name: Snapshot this action for the whole run
202
197
  shell: bash
203
198
  run: |
@@ -233,6 +228,8 @@ runs:
233
228
  mkdir -p "$RUNNER_TEMP/swift-app-bot-scripts"
234
229
  cp "$SWIFT_APP_ACTION/scripts/autoupdate_check.sh" \
235
230
  "$RUNNER_TEMP/swift-app-bot-scripts/"
231
+ cp "$SWIFT_APP_ACTION/scripts/autoupdate_stage.py" \
232
+ "$RUNNER_TEMP/swift-app-bot-scripts/"
236
233
  cp "$SWIFT_APP_ACTION/scripts/commit_bot_changes.sh" \
237
234
  "$RUNNER_TEMP/swift-app-bot-scripts/"
238
235
  cp "$SWIFT_APP_ACTION/scripts/source_maintenance.py" \
@@ -243,8 +240,8 @@ runs:
243
240
  - name: Check for app-ci update
244
241
  # Default branch + push event only. `auto-update` is on by default;
245
242
  # consumers can pin via `false`. Runs from $BOT_SCRIPTS_DIR
246
- # (RUNNER_TEMP) so its own `npx` overwriting ${action_path} cannot
247
- # swap the script under us.
243
+ # (RUNNER_TEMP); installation and candidate preservation run in an isolated
244
+ # checkout so current action manifests and scripts remain unchanged.
248
245
  if: ${{ inputs.auto-update == 'true'
249
246
  && github.event_name == 'push'
250
247
  && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
@@ -521,8 +518,7 @@ runs:
521
518
  # this action; this script is a no-op when nothing is staged, so a
522
519
  # PR / feature-branch run that somehow reaches this step does no
523
520
  # harm — but we still gate on default-branch push for clarity.
524
- # Runs from $BOT_SCRIPTS_DIR (RUNNER_TEMP): the autoupdate re-vendored
525
- # ${action_path} earlier in this run and must not swap scripts under us.
521
+ # Runs from the same immutable $BOT_SCRIPTS_DIR as the update candidate.
526
522
  if: ${{ (inputs.archive == 'true' || inputs.auto-update == 'true')
527
523
  && github.event_name == 'push'
528
524
  && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
- # Checks npm for newer gowalk-cicd, re-vendors via npx if newer.
3
- # Stages refreshed files for commit. Exits 0 on no-op or non-fatal failure.
2
+ # Checks npm for newer gowalk-cicd and preserves an isolated update candidate.
3
+ # Exits 0 on no-op or package-install failure; unconfirmed source preservation fails.
4
4
  #
5
5
  # DESIGN DECISION (intentional, not a bug):
6
6
  # This script runs `npx --yes gowalk-cicd@<latest>` on every default-
@@ -53,66 +53,9 @@ if [[ "$current" == "$latest" ]]; then
53
53
  exit 0
54
54
  fi
55
55
 
56
- echo "autoupdate: $PKG $current -> $latest; re-vendoring"
57
-
58
- if ! npx --yes "$PKG@$latest"; then
59
- echo "::warning::autoupdate: npx $PKG@$latest failed"
60
- exit 0
61
- fi
62
-
63
- git config user.name >/dev/null 2>&1 || git config user.name "github-actions[bot]"
64
- git config user.email >/dev/null 2>&1 || \
65
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
66
-
67
- # NOTE: We deliberately do NOT stage .github/workflows/deploy.yml here.
68
- # GITHUB_TOKEN cannot push workflow-file changes regardless of
69
- # `permissions: contents: write` (GH security policy: prevents CI from
70
- # modifying its own triggers). Consumer-side deploy.yml updates require
71
- # manual `npx --yes gowalk-cicd` by the user. Document this in
72
- # the action README and changelog when shipping a deploy.yml schema change.
73
-
74
- # Stage everything under the vendored action dir EXCEPT Python bytecode
75
- # caches. The action's own scripts run during this CI job and Python
76
- # generates `__pycache__/*.pyc` files inside scripts/ as a side effect of
77
- # importing them. Those caches are build artifacts of *this* run, not part
78
- # of the action distribution -- sweeping them into the autoupdate bot
79
- # commit is benign but noisy (every clean run emits a "refresh
80
- # __pycache__" diff).
81
- #
82
- # DESIGN DECISION: use `git add -A` (stages additions, modifications, AND
83
- # deletions) instead of the previous `git ls-files -mo` flow. `ls-files
84
- # -mo` enumerates modified + other (untracked) paths but NOT deletions:
85
- # when an autoupdate cycle removes a script (e.g. `prepare_signing.py`
86
- # gets renamed to `prepare_signing_v2.py` in a new release), the old
87
- # file would stay tracked in the consumer repo forever -- the bot
88
- # commit would add the new file but never remove the orphan. `git add
89
- # -A` handles all three change kinds in one call. We then unstage any
90
- # pycache paths that the broad `-A` swept in.
91
- #
92
- # Force flag (`-f`) is implicit in `git add -A` for explicitly listed
93
- # pathspecs that match .gitignore patterns? No -- `-A` still honors
94
- # .gitignore for untracked files. To preserve the prior semantics
95
- # (consumer's .gitignore must not silently drop autoupdate-managed
96
- # files like `.daemux-version`), we pass `--force` explicitly.
97
- #
98
- # BSD xargs (default on macos-15 runners) does NOT support
99
- # `-r`/`--no-run-if-empty`, so we capture pycache paths into a variable
100
- # and skip the xargs call when empty -- portable across BSD and GNU.
101
- git add -A --force -- \
102
- .github/actions/swift-app/ \
103
- .github/actions/android-app/ 2>/dev/null || true
104
-
105
- # Unstage Python bytecode that the broad `-A` swept in. `git diff
106
- # --cached --name-only` lists every staged path; we filter to pycache
107
- # entries and `git reset HEAD --` to drop them from the index. Working
108
- # tree is left untouched (the .pyc files remain on disk; only their
109
- # index entries are removed).
110
- pycache="$(git diff --cached --name-only -- \
111
- .github/actions/swift-app/ .github/actions/android-app/ \
112
- | grep -E '(/__pycache__/|\.pyc$)' || true)"
113
- if [[ -n "$pycache" ]]; then
114
- printf '%s\n' "$pycache" \
115
- | tr '\n' '\0' \
116
- | xargs -0 git reset HEAD -- 2>/dev/null || true
117
- fi
118
- echo "autoupdate: staged refreshed iOS + Android actions (deploy.yml not staged; __pycache__ excluded)"
56
+ # The runner reparses local action.yml files for post hooks and reuses the step IDs
57
+ # it cached before execution. Updating these files in place can change their step
58
+ # count and crash cleanup. Publish the update candidate from a detached worktree;
59
+ # the current job keeps its exact manifests, scripts, index and uncommitted source.
60
+ echo "autoupdate: $PKG $current -> $latest; preparing an isolated source candidate"
61
+ exec python3 "$(dirname "$0")/autoupdate_stage.py" "$latest"
@@ -0,0 +1,62 @@
1
+ """Prepare CI updates away from action manifests the current runner may reload."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+
12
+
13
+ def git(root: Path, *args: str) -> str:
14
+ result = subprocess.run(["git", *args], cwd=root, capture_output=True,
15
+ text=True, check=False, timeout=60)
16
+ if result.returncode:
17
+ raise RuntimeError("autoupdate_worktree_git_failed")
18
+ return result.stdout.strip()
19
+
20
+
21
+ def stage(root: Path) -> None:
22
+ git(root, "add", "-A", "--force", "--", ".github/actions/swift-app/", ".github/actions/android-app/")
23
+ names = git(root, "diff", "--cached", "--name-only", "-z").split("\0")
24
+ bytecode = [name for name in names if "/__pycache__/" in name or name.endswith((".pyc", ".pyo"))]
25
+ if bytecode:
26
+ git(root, "reset", "--quiet", "HEAD", "--", *bytecode)
27
+
28
+
29
+ def update(version: str) -> None:
30
+ if not re.fullmatch(r"\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?", version):
31
+ raise RuntimeError("autoupdate_version_invalid")
32
+ root = Path.cwd()
33
+ base = git(root, "rev-parse", "HEAD")
34
+ parent = Path(tempfile.mkdtemp(prefix="app-ci-update-", dir=os.environ["RUNNER_TEMP"]))
35
+ worktree = parent / "checkout"
36
+ added = False
37
+ try:
38
+ git(root, "worktree", "add", "--detach", str(worktree), base)
39
+ added = True
40
+ result = subprocess.run(["npx", "--yes", "gowalk-cicd@" + version], cwd=worktree,
41
+ check=False, timeout=180)
42
+ if result.returncode:
43
+ print("::warning::autoupdate: pinned package installation failed; keeping the current build")
44
+ return
45
+ stage(worktree)
46
+ script = Path(__file__).with_name("commit_bot_changes.sh")
47
+ result = subprocess.run(["bash", str(script)], cwd=worktree, check=False, timeout=180)
48
+ if result.returncode:
49
+ raise RuntimeError("autoupdate_source_preservation_failed")
50
+ finally:
51
+ if added:
52
+ git(root, "worktree", "remove", "--force", str(worktree))
53
+ shutil.rmtree(parent)
54
+
55
+
56
+ if __name__ == "__main__":
57
+ try:
58
+ update(sys.argv[1])
59
+ except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
60
+ code = str(exc) if isinstance(exc, RuntimeError) else "autoupdate_operation_unverified"
61
+ print("::error::" + code, file=sys.stderr)
62
+ raise SystemExit(1) from None
@@ -0,0 +1,107 @@
1
+ """Exercise real isolated installs and protected-remote publication without changing a live action."""
2
+ from pathlib import Path
3
+ import re
4
+ import subprocess
5
+ import textwrap
6
+ import unittest
7
+
8
+ from source_maintenance_fixture import MaintenanceFixture
9
+
10
+
11
+ class AutoupdateTests(MaintenanceFixture):
12
+ def setUp(self):
13
+ super().setUp()
14
+ self.remote_base = self.base
15
+ self.action = self.repo / ".github/actions/android-app/flutter-setup/action.yml"
16
+ self.marker = self.repo / ".github/actions/swift-app/.daemux-version"
17
+ self.action.parent.mkdir(parents=True)
18
+ self.marker.parent.mkdir(parents=True)
19
+ self.action.write_text("runs:\n using: composite\n steps:\n - run: old\n shell: bash\n")
20
+ self.marker.write_text("1.2.3")
21
+ self.git("add", ".github")
22
+ self.git("commit", "-m", "Existing action")
23
+ self.base = self.git("rev-parse", "HEAD").strip()
24
+ self.env["GITHUB_SHA"] = self.base
25
+ config = self.root / "checkout-auth.config"
26
+ self.git("config", "--file", str(config), "fixture.token", "worktree-allowed")
27
+ self.git("config", "includeIf.gitdir:" + str((self.repo / ".git").resolve()) + "/worktrees/*.path",
28
+ str(config))
29
+ self.scratch = self.root / "runner-temp"
30
+ self.scratch.mkdir()
31
+ self.env["RUNNER_TEMP"] = str(self.scratch)
32
+ binary = self.root / "bin"
33
+ binary.mkdir()
34
+ (binary / "npm").write_text("#!/bin/sh\nprintf '1.2.4\\n'\n")
35
+ (binary / "npm").chmod(0o755)
36
+ self.installer = binary / "npx"
37
+ self.installer.write_text("#!/bin/sh\nset -eu\n"
38
+ "[ \"$1\" = --yes ] && [ \"$2\" = gowalk-cicd@1.2.4 ]\n"
39
+ "[ \"$(git config --get fixture.token)\" = worktree-allowed ]\n"
40
+ "printf '1.2.4' > .github/actions/swift-app/.daemux-version\n"
41
+ "printf ' - run: added\\n shell: bash\\n' >> .github/actions/android-app/flutter-setup/action.yml\n"
42
+ "mkdir -p .github/workflows .github/actions/swift-app/scripts/__pycache__\n"
43
+ "printf 'never publish workflow' > .github/workflows/deploy.yml\n"
44
+ "printf 'never publish bytecode' > .github/actions/swift-app/scripts/__pycache__/test.pyc\n"
45
+ "exit ${INSTALL_EXIT:-0}\n")
46
+ self.installer.chmod(0o755)
47
+ self.env["PATH"] = str(binary) + ":" + self.env["PATH"]
48
+ self.script = Path(__file__).with_name("autoupdate_check.sh")
49
+
50
+ def assert_current_build_preserved(self, original, staged):
51
+ self.assertEqual(self.action.read_bytes(), original)
52
+ self.assertEqual(self.marker.read_text(), "1.2.3")
53
+ self.assertEqual(self.git("diff", "--cached"), staged)
54
+ self.assertEqual(self.git("rev-parse", "HEAD").strip(), self.base)
55
+ self.assertEqual(self.git("ls-remote", "origin", "refs/heads/main").split()[0], self.remote_base)
56
+ self.assertEqual(len(self.git("worktree", "list", "--porcelain").split("worktree ")) - 1, 1)
57
+ self.assertEqual(list(self.scratch.iterdir()), [])
58
+
59
+ def test_growing_composite_and_replay_preserve_current_build_and_publish_exact_update(self):
60
+ self.stage()
61
+ original, staged = self.action.read_bytes(), self.git("diff", "--cached")
62
+ first = self.receipt(self.run_script())
63
+ self.assert_current_build_preserved(original, staged)
64
+ updated = self.git("show", first["head_sha"] + ":.github/actions/android-app/flutter-setup/action.yml")
65
+ self.assertEqual(updated.count("- run:"), 2)
66
+ self.assertEqual(original.decode().count("- run:"), 1)
67
+ paths = self.git("diff", "--name-only", self.base, first["head_sha"]).splitlines()
68
+ self.assertEqual(paths, [".github/actions/android-app/flutter-setup/action.yml",
69
+ ".github/actions/swift-app/.daemux-version"])
70
+ self.assertEqual(self.receipt(self.run_script()), first)
71
+ self.assert_current_build_preserved(original, staged)
72
+
73
+ def test_failed_partial_install_keeps_current_action_and_cleans_owned_worktree(self):
74
+ self.stage()
75
+ original, staged = self.action.read_bytes(), self.git("diff", "--cached")
76
+ self.env["INSTALL_EXIT"] = "1"
77
+ result = self.run_script()
78
+ self.assertEqual(result.returncode, 0, result.stderr)
79
+ self.assertIn("pinned package installation failed", result.stdout)
80
+ self.assertNotIn("source_maintenance_pending", result.stdout)
81
+ self.assert_current_build_preserved(original, staged)
82
+ self.assertEqual(len(self.git("ls-remote", "--heads", "origin").splitlines()), 1)
83
+
84
+ def test_unconfirmed_preservation_is_not_a_success(self):
85
+ self.stage()
86
+ original, staged = self.action.read_bytes(), self.git("diff", "--cached")
87
+ self.hook.write_text("#!/bin/sh\nexit 1\n")
88
+ result = self.run_script()
89
+ self.assertNotEqual(result.returncode, 0)
90
+ self.assertIn("autoupdate_source_preservation_failed", result.stderr)
91
+ self.assert_current_build_preserved(original, staged)
92
+
93
+ def test_actual_action_snapshot_can_execute_the_update_helper(self):
94
+ action = Path(__file__).resolve().parents[1]
95
+ source = (action / "action.yml").read_text()
96
+ block = re.search(r" - name: Stage bot-side scripts to runner temp\n(.*?)(?=\n - name:)",
97
+ source, re.S)[1]
98
+ script = textwrap.dedent(block.split(" run: |\n", 1)[1])
99
+ self.env.update({"SWIFT_APP_ACTION": str(action), "GITHUB_ENV": str(self.root / "environment")})
100
+ subprocess.run(["bash", "-e", "-c", script], env=self.env, cwd=self.repo, check=True,
101
+ text=True, capture_output=True, timeout=10)
102
+ self.script = self.scratch / "swift-app-bot-scripts/autoupdate_check.sh"
103
+ self.assertEqual(self.receipt(self.run_script())["base_sha"], self.base)
104
+
105
+
106
+ if __name__ == "__main__":
107
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.95
1
+ 1.0.97
@@ -138,6 +138,18 @@ runs:
138
138
  with:
139
139
  path: ${{ steps.flutter-action.outputs.PUB-CACHE-PATH }}
140
140
  key: ${{ steps.flutter-action.outputs.PUB-CACHE-KEY }}-${{ hashFiles('**/pubspec.lock') }}
141
+ - name: Restore interrupted Flutter archive
142
+ if: ${{ inputs.cache == 'true' && inputs.dry-run != 'true' && steps.cache-flutter.outputs.cache-hit != 'true' }}
143
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25
144
+ continue-on-error: true
145
+ env:
146
+ NODE_OPTIONS: --require "${{ steps.scope.outputs.preloader }}"
147
+ SEGMENT_DOWNLOAD_TIMEOUT_MINS: '1'
148
+ with:
149
+ path: ${{ runner.temp }}/gowalk-flutter-archives
150
+ key: >-
151
+ flutter-archive-v1-${{ steps.flutter-action.outputs.CACHE-KEY }}-${{ github.run_id }}-${{ github.run_attempt }}
152
+ restore-keys: flutter-archive-v1-${{ steps.flutter-action.outputs.CACHE-KEY }}-
141
153
  - name: Install or reuse the resolved Flutter SDK
142
154
  id: install
143
155
  if: ${{ inputs.dry-run != 'true' }}
@@ -145,6 +157,8 @@ runs:
145
157
  env:
146
158
  BASH_ENV: ${{ steps.transport.outputs.manifest_env }}
147
159
  GOWALK_FLUTTER_MANIFEST_HELPER: ${{ steps.transport.outputs.manifest_helper }}
160
+ GOWALK_FLUTTER_ARCHIVE_CACHE: >-
161
+ ${{ inputs.cache == 'true' && format('{0}/gowalk-flutter-archives', runner.temp) || '' }}
148
162
  with:
149
163
  channel: ${{ steps.flutter-action.outputs.CHANNEL }}
150
164
  flutter-version: ${{ steps.flutter-action.outputs.VERSION }}
@@ -158,6 +172,18 @@ runs:
158
172
  pub-cache-path: ${{ steps.flutter-action.outputs.PUB-CACHE-PATH }}
159
173
  dry-run: 'false'
160
174
  git-source: ${{ inputs.git-source }}
175
+ - name: Preserve interrupted Flutter archive
176
+ if: >-
177
+ ${{ !cancelled() && inputs.cache == 'true' && steps.install.outcome == 'failure'
178
+ && steps.scope.outcome == 'success' }}
179
+ uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25
180
+ continue-on-error: true
181
+ env:
182
+ NODE_OPTIONS: --require "${{ steps.scope.outputs.preloader }}"
183
+ with:
184
+ path: ${{ runner.temp }}/gowalk-flutter-archives
185
+ key: >-
186
+ flutter-archive-v1-${{ steps.flutter-action.outputs.CACHE-KEY }}-${{ github.run_id }}-${{ github.run_attempt }}
161
187
  # A later app failure must not discard a successfully installed, expensive SDK.
162
188
  - name: Save installed Flutter SDK
163
189
  if: >-
@@ -0,0 +1,47 @@
1
+ """Keep only public SDK archive bytes in the job's optional, digest-scoped download cache."""
2
+ from contextlib import contextmanager
3
+ import os
4
+ from pathlib import Path
5
+ import tempfile
6
+
7
+ import flutter_download
8
+
9
+
10
+ def cached_directory() -> Path:
11
+ root = Path(os.environ["GOWALK_FLUTTER_ARCHIVE_CACHE"])
12
+ temporary = Path(os.environ["RUNNER_TEMP"]).resolve(strict=True)
13
+ if root.is_symlink() or root.parent.resolve(strict=True) != temporary:
14
+ raise ValueError("flutter_archive_cache_scope_invalid")
15
+ root.mkdir(mode=0o700, exist_ok=True)
16
+ directory = root / flutter_download.archive_digest()
17
+ if directory.is_symlink():
18
+ raise ValueError("flutter_archive_cache_scope_invalid")
19
+ directory.mkdir(mode=0o700, exist_ok=True)
20
+ destination = directory / "download"
21
+ if destination.is_symlink() or (destination.exists() and not destination.is_file()):
22
+ raise ValueError("flutter_archive_cache_scope_invalid")
23
+ if destination.exists() and destination.stat().st_size > flutter_download.MAX_ARCHIVE_BYTES:
24
+ raise ValueError("flutter_archive_size_exceeded")
25
+ return directory
26
+
27
+
28
+ @contextmanager
29
+ def workspace(kind: str):
30
+ if kind != "archive" or not os.environ.get("GOWALK_FLUTTER_ARCHIVE_CACHE"):
31
+ with tempfile.TemporaryDirectory(prefix="flutter-manifest-", dir=os.environ["RUNNER_TEMP"]) as scratch:
32
+ yield Path(scratch)
33
+ return
34
+ directory = cached_directory()
35
+ try:
36
+ yield directory
37
+ except ValueError as error:
38
+ if str(error) == "flutter_archive_checksum_mismatch":
39
+ (directory / "download").unlink(missing_ok=True)
40
+ raise
41
+
42
+
43
+ def reuse_complete(destination: Path, kind: str) -> bool:
44
+ if kind != "archive" or not destination.stat().st_size or not flutter_download.archive_matches(destination):
45
+ return False
46
+ flutter_download.publish(destination, kind)
47
+ return True
@@ -63,18 +63,21 @@ def progress(destination: Path, started: float, event: str, **fields) -> None:
63
63
  file=sys.stderr, flush=True)
64
64
 
65
65
 
66
+ def archive_matches(destination: Path) -> bool:
67
+ if destination.stat().st_size > MAX_ARCHIVE_BYTES:
68
+ raise ValueError("flutter_archive_size_exceeded")
69
+ digest = hashlib.sha256()
70
+ with destination.open("rb") as source:
71
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
72
+ digest.update(chunk)
73
+ return digest.hexdigest() == archive_digest()
74
+
75
+
66
76
  def publish(destination: Path, kind: str) -> None:
67
77
  if kind == "manifest":
68
78
  json.loads(destination.read_bytes())
69
- else:
70
- if destination.stat().st_size > MAX_ARCHIVE_BYTES:
71
- raise ValueError("flutter_archive_size_exceeded")
72
- digest = hashlib.sha256()
73
- with destination.open("rb") as source:
74
- for chunk in iter(lambda: source.read(1024 * 1024), b""):
75
- digest.update(chunk)
76
- if digest.hexdigest() != archive_digest():
77
- raise ValueError("flutter_archive_checksum_mismatch")
79
+ elif not archive_matches(destination):
80
+ raise ValueError("flutter_archive_checksum_mismatch")
78
81
  with destination.open("rb") as source:
79
82
  shutil.copyfileobj(source, sys.stdout.buffer, length=1024 * 1024)
80
83
 
@@ -5,11 +5,11 @@ import os
5
5
  from pathlib import Path
6
6
  import re
7
7
  import sys
8
- import tempfile
9
8
  import time
10
9
  from urllib.parse import urlsplit
11
10
 
12
11
  import flutter_download
12
+ import flutter_archive_cache
13
13
 
14
14
  RETRY_CODES = {5, 6, 7, 18, 28, 35, 52, 55, 56, 92, 95}
15
15
  RETRY_HTTP = {408, 429, 500, 502, 503, 504}
@@ -24,8 +24,10 @@ def download(arguments: list[str], directory: Path) -> int:
24
24
  child_env = {**os.environ, "NO_PROXY": "", "no_proxy": ""}
25
25
  deadline, code, http, proxy_http = time.monotonic() + (60 if kind == "manifest" else 1200), 1, 0, 0
26
26
  destination = directory / "download"
27
- destination.touch(mode=0o600, exist_ok=False)
27
+ destination.touch(mode=0o600, exist_ok=True)
28
28
  destination.chmod(0o600)
29
+ if flutter_archive_cache.reuse_complete(destination, kind):
30
+ return 0
29
31
  attempted = 0
30
32
  for attempt in range(1, 5):
31
33
  remaining = deadline - time.monotonic()
@@ -74,8 +76,11 @@ def request_kind(arguments):
74
76
 
75
77
  def main() -> int:
76
78
  try:
77
- with tempfile.TemporaryDirectory(prefix="flutter-manifest-", dir=os.environ["RUNNER_TEMP"]) as scratch:
78
- return download(sys.argv[1:], Path(scratch))
79
+ with flutter_archive_cache.workspace(request_kind(sys.argv[1:])) as scratch:
80
+ result = download(sys.argv[1:], scratch)
81
+ if result == 0:
82
+ (scratch / "download").unlink(missing_ok=True)
83
+ return result
79
84
  except (ValueError, OSError, KeyError) as error:
80
85
  safe = {"flutter_archive_digest_required", "flutter_archive_size_exceeded", "flutter_archive_checksum_mismatch"}
81
86
  reason = str(error) if str(error) in safe else "flutter_manifest_invalid_request_or_response"
@@ -0,0 +1,102 @@
1
+ """A later job resumes retained public SDK bytes without bypassing digest or account checks."""
2
+ import hashlib
3
+ import os
4
+ from pathlib import Path
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from unittest.mock import patch
11
+
12
+ from flutter_manifest_fixture import tls_proxy
13
+ import test_flutter_archive as archive_fixture
14
+
15
+ ARCHIVE, ROOT = archive_fixture.ARCHIVE, archive_fixture.ROOT
16
+ DIGEST = hashlib.sha256(ARCHIVE).hexdigest()
17
+
18
+
19
+ def invoke(root, server, cert, **kwargs):
20
+ with patch.dict(os.environ, {"GOWALK_FLUTTER_ARCHIVE_CACHE": str(root / "gowalk-flutter-archives")}):
21
+ return archive_fixture.ArchiveRecoveryTests().invoke(root, server, cert, **kwargs)
22
+
23
+
24
+ class ArchiveCacheTests(unittest.TestCase):
25
+ def test_later_job_resumes_after_the_previous_job_exhausted_its_retries(self):
26
+ with tempfile.TemporaryDirectory() as first, tempfile.TemporaryDirectory() as second:
27
+ root, resumed = Path(first), Path(second)
28
+ with tls_proxy(root, ["partial", "range_partial", "range_partial", "range_partial"],
29
+ ARCHIVE) as (server, cert):
30
+ result = invoke(root, server, cert)
31
+ self.assertEqual((result.returncode, result.stdout), (18, b""))
32
+ partial = root / "gowalk-flutter-archives" / DIGEST / "download"
33
+ self.assertEqual(partial.read_bytes(), ARCHIVE[:124])
34
+ shutil.copytree(root / "gowalk-flutter-archives", resumed / "gowalk-flutter-archives")
35
+ with tls_proxy(resumed, ["range"], ARCHIVE) as (server, cert):
36
+ result = invoke(resumed, server, cert)
37
+ self.assertEqual((result.returncode, result.stdout), (0, ARCHIVE))
38
+ self.assertEqual(server.ranges, ["bytes=124-"])
39
+ self.assertFalse((resumed / "gowalk-flutter-archives" / DIGEST / "download").exists())
40
+
41
+ def test_complete_cached_archive_is_verified_without_another_archive_request(self):
42
+ with tempfile.TemporaryDirectory() as directory:
43
+ root = Path(directory)
44
+ partial = root / "gowalk-flutter-archives" / DIGEST / "download"
45
+ partial.parent.mkdir(parents=True)
46
+ partial.write_bytes(ARCHIVE)
47
+ with tls_proxy(root, [], ARCHIVE) as (server, cert):
48
+ result = invoke(root, server, cert)
49
+ self.assertEqual((result.returncode, result.stdout, server.connects), (0, ARCHIVE, 0))
50
+
51
+ def test_complete_cached_bytes_cannot_bypass_a_missing_proxy(self):
52
+ with tempfile.TemporaryDirectory() as directory:
53
+ root = Path(directory)
54
+ partial = root / "gowalk-flutter-archives" / DIGEST / "download"
55
+ partial.parent.mkdir(parents=True)
56
+ partial.write_bytes(ARCHIVE)
57
+ env = {**os.environ, "RUNNER_TEMP": str(root), "GOWALK_FLUTTER_ARCHIVE_SHA256": DIGEST,
58
+ "GOWALK_FLUTTER_ARCHIVE_CACHE": str(root / "gowalk-flutter-archives"),
59
+ "HTTPS_PROXY": "", "https_proxy": ""}
60
+ url = "https://provider.invalid/flutter_infra_release/releases/stable/sdk.tar.xz"
61
+ result = subprocess.run([sys.executable, str(ROOT / "flutter_manifest.py"),
62
+ "--connect-timeout", "15", "--retry", "5", url],
63
+ env=env, capture_output=True, timeout=10)
64
+ self.assertEqual((result.returncode, result.stdout), (1, b""))
65
+ self.assertEqual(partial.read_bytes(), ARCHIVE)
66
+
67
+ def test_changed_digest_cannot_reuse_another_release_partial(self):
68
+ with tempfile.TemporaryDirectory() as directory:
69
+ root = Path(directory)
70
+ foreign = root / "gowalk-flutter-archives" / ("a" * 64) / "download"
71
+ foreign.parent.mkdir(parents=True)
72
+ foreign.write_bytes(b"unrelated release")
73
+ with tls_proxy(root, ["ok"], ARCHIVE) as (server, cert):
74
+ result = invoke(root, server, cert)
75
+ self.assertEqual((result.returncode, result.stdout), (0, ARCHIVE))
76
+ self.assertEqual(server.ranges, [""])
77
+ self.assertEqual(foreign.read_bytes(), b"unrelated release")
78
+
79
+ def test_corrupted_restored_partial_fails_verification_and_is_not_cached_again(self):
80
+ with tempfile.TemporaryDirectory() as directory:
81
+ root = Path(directory)
82
+ partial = root / "gowalk-flutter-archives" / DIGEST / "download"
83
+ partial.parent.mkdir(parents=True)
84
+ partial.write_bytes(b"wrong bytes")
85
+ with tls_proxy(root, ["range"], ARCHIVE) as (server, cert):
86
+ result = invoke(root, server, cert)
87
+ self.assertEqual((result.returncode, result.stdout), (1, b""))
88
+ self.assertEqual(server.ranges, ["bytes=11-"])
89
+ self.assertFalse(partial.exists())
90
+
91
+ def test_symlinked_cache_does_not_read_or_write_another_directory(self):
92
+ with tempfile.TemporaryDirectory() as directory, tempfile.TemporaryDirectory() as outside:
93
+ root = Path(directory)
94
+ (root / "gowalk-flutter-archives").symlink_to(outside, target_is_directory=True)
95
+ with tls_proxy(root, [], ARCHIVE) as (server, cert):
96
+ result = invoke(root, server, cert)
97
+ self.assertEqual((result.returncode, result.stdout, server.connects), (1, b"", 0))
98
+ self.assertEqual(list(Path(outside).iterdir()), [])
99
+
100
+
101
+ if __name__ == "__main__":
102
+ unittest.main()
@@ -24,7 +24,11 @@ class BootstrapWiringTests(unittest.TestCase):
24
24
  self.assertNotIn("continue-on-error", step)
25
25
  self.assertEqual(checked, 3)
26
26
  setup = yaml.safe_load((ROOT / "android-action/flutter-setup/action.yml").read_text())
27
- guard, scope, resolve, sdk_cache, pub_cache, install, save = setup["runs"]["steps"]
27
+ steps = setup["runs"]["steps"]
28
+ by_id = {step.get("id"): step for step in steps}
29
+ guard, scope, resolve, sdk_cache, pub_cache, install = (
30
+ by_id[name] for name in ("transport", "scope", "flutter-action", "cache-flutter", "cache-pub", "install"))
31
+ save = next(step for step in steps if step["name"] == "Save installed Flutter SDK")
28
32
  self.assertIn('play_store_proxy.py" --bootstrap-env', guard["run"])
29
33
  self.assertNotIn("continue-on-error", guard)
30
34
  self.assertTrue(scope["continue-on-error"])
@@ -32,9 +36,13 @@ class BootstrapWiringTests(unittest.TestCase):
32
36
  self.assertEqual(resolve["with"]["dry-run"], "true")
33
37
  for step in (resolve, install):
34
38
  self.assertEqual(step["uses"], "subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2")
35
- self.assertEqual(step["env"], {
39
+ expected_env = {
36
40
  "BASH_ENV": "${{ steps.transport.outputs.manifest_env }}",
37
- "GOWALK_FLUTTER_MANIFEST_HELPER": "${{ steps.transport.outputs.manifest_helper }}"})
41
+ "GOWALK_FLUTTER_MANIFEST_HELPER": "${{ steps.transport.outputs.manifest_helper }}"}
42
+ if step is install:
43
+ expected_env["GOWALK_FLUTTER_ARCHIVE_CACHE"] = (
44
+ "${{ inputs.cache == 'true' && format('{0}/gowalk-flutter-archives', runner.temp) || '' }}")
45
+ self.assertEqual(step["env"], expected_env)
38
46
  self.assertEqual(step["with"]["cache"], "false")
39
47
  self.assertEqual(step["with"]["pub-cache"], "false")
40
48
  self.assertNotIn("continue-on-error", step)
@@ -66,7 +74,8 @@ class BootstrapWiringTests(unittest.TestCase):
66
74
  self.assertEqual(restore["uses"],
67
75
  "actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25")
68
76
  install = next(step for step in steps if step.get("id") == "install")
69
- save = steps[steps.index(install) + 1]
77
+ save = next(step for step in steps if step["name"] == "Save installed Flutter SDK")
78
+ self.assertGreater(steps.index(save), steps.index(install))
70
79
  self.assertEqual(save["uses"], "actions/cache/save@caa296126883cff596d87d8935842f9db880ef25")
71
80
  self.assertEqual(save["with"], restore["with"])
72
81
  self.assertTrue(save["continue-on-error"])
@@ -1 +1 @@
1
- 1.0.95
1
+ 1.0.97
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.95",
3
+ "version": "1.0.97",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {