From 3b468aecb504b033d9e53583fc4a2435152d9f84 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 13 Oct 2026 01:17:01 +1100 Subject: [PATCH 1/2] fix(ci): commit the Go release's guests 0644 in the build job too The first release, v0.1.0-alpha.1, refused itself: publish's tree check found 76409f6 where build had tested f274561. Cargo writes the .wasm guests executable, and the build job committed them as they were (100755), while publish writes them 0644 from the artifact (100644). The bytes matched; only the mode differed. Nothing was pushed. The build job now sets the guests to 0644 before it stages them, so both jobs build the same tree. A test runs the real staging step on executable guests and checks the tree it reports is the one publish builds; it fails without the chmod. --- .github/workflows/release-golang.yml | 5 ++ .../__tests__/go-release-artifact.test.mjs | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/.github/workflows/release-golang.yml b/.github/workflows/release-golang.yml index 59a325f08..b7ad2deaa 100644 --- a/.github/workflows/release-golang.yml +++ b/.github/workflows/release-golang.yml @@ -205,6 +205,11 @@ jobs: git checkout --detach # The guests are gitignored on main; -f adds them to this commit only. read -ra guests <<< "$GUESTS" + # Cargo writes the guests executable, and `publish` writes them + # 0644 from the artifact. Commit them 0644 here too, or the tree + # this job tests is not the one `publish` builds, and it refuses + # the release. + chmod 0644 "${guests[@]}" git add -f "${guests[@]}" git commit -q -m "release(golang): v$VERSION with its WASI guests" tree=$(git rev-parse 'HEAD^{tree}') diff --git a/scripts/__tests__/go-release-artifact.test.mjs b/scripts/__tests__/go-release-artifact.test.mjs index 6129de716..01d4122bf 100644 --- a/scripts/__tests__/go-release-artifact.test.mjs +++ b/scripts/__tests__/go-release-artifact.test.mjs @@ -238,3 +238,50 @@ describe.skipIf(!BASH_HAS_MAPFILE)( }) }, ) + +/** + * `build` reports the tree it tested and `publish` refuses any other, so the + * two must stage the guests identically. Cargo writes them executable and + * `publish` writes them 0644 from the artifact, which made the trees differ + * in mode alone: the first release refused itself. This runs the build job's + * staging, up to the tree it reports, on executable guests. + */ +const STAGE_STEP = WORKFLOW.jobs.build.steps.find((step) => step.id === 'stage') +const STAGE_UNTIL = 'fetched=$(mktemp -d)' + +describe('release-golang.yml build: Stage the release commit', () => { + it('reports the tree publish builds, whatever mode cargo left the guests in', () => { + expect(STAGE_STEP).toBeDefined() + const script = String(STAGE_STEP.run) + const cut = script.indexOf(STAGE_UNTIL) + expect( + cut, + `the stage step no longer contains ${STAGE_UNTIL}`, + ).toBeGreaterThan(0) + + for (const guest of GUESTS) { + stage(guest, `guest ${guest}\n`) + const built = join(repo, guest) + mkdirSync(dirname(built), { recursive: true }) + writeFileSync(built, `guest ${guest}\n`, { mode: 0o755 }) + } + const result = spawnSync( + 'bash', + ['-c', `${script.slice(0, cut)}\necho "$tree"`], + { + cwd: repo, + encoding: 'utf8', + env: { + PATH: process.env.PATH, + HOME: repo, + GIT_CONFIG_NOSYSTEM: '1', + GUESTS: WORKFLOW.env.GUESTS, + VERSION: '0.1.0-alpha.1', + }, + }, + ) + expect(result.status, result.stderr).toBe(0) + git(repo, 'checkout', '-q', 'main') + expect(result.stdout.trim().split('\n').at(-1)).toBe(expectedTree()) + }) +}) From 8311bcdf461e9fec8b2a0537b3f0dd8c022597e6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 13 Oct 2026 01:17:01 +1100 Subject: [PATCH 2/2] fix(ci): set the Go release's guest mode in git, and test against the real publish step Review follow-up. chmod made the two jobs agree only while both kept writing the files 0644 by their own route. Both jobs now stage the guests with git add --chmod=-x, so the committed mode is set in git whatever either job left on disk. The test no longer compares the build job's tree with a tree it builds itself at 100644. It runs the build job's staging on executable guests, then runs the real publish step on an executable artifact against the tree the build job reported, and expects publish to accept it. --- .github/workflows/release-golang.yml | 15 +-- .../__tests__/go-release-artifact.test.mjs | 102 ++++++++++++------ 2 files changed, 78 insertions(+), 39 deletions(-) diff --git a/.github/workflows/release-golang.yml b/.github/workflows/release-golang.yml index b7ad2deaa..0db79e34a 100644 --- a/.github/workflows/release-golang.yml +++ b/.github/workflows/release-golang.yml @@ -205,12 +205,11 @@ jobs: git checkout --detach # The guests are gitignored on main; -f adds them to this commit only. read -ra guests <<< "$GUESTS" - # Cargo writes the guests executable, and `publish` writes them - # 0644 from the artifact. Commit them 0644 here too, or the tree - # this job tests is not the one `publish` builds, and it refuses - # the release. - chmod 0644 "${guests[@]}" - git add -f "${guests[@]}" + # --chmod=-x: the committed mode is set in git, not read from the + # file. Cargo writes the guests executable and `publish` writes them + # 0644, so a mode taken from disk made this job's tree differ from + # the one `publish` builds, and it refused the release. + git add -f --chmod=-x "${guests[@]}" git commit -q -m "release(golang): v$VERSION with its WASI guests" tree=$(git rev-parse 'HEAD^{tree}') @@ -303,7 +302,9 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout --detach - git add -f "${guests[@]}" + # The mode is set in git, as the build job sets it, so the two trees + # agree whatever mode either job left the files in. + git add -f --chmod=-x "${guests[@]}" git commit -m "release(golang): v$VERSION with its WASI guests" \ -m "Built from $GITHUB_SHA by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" tree=$(git rev-parse 'HEAD^{tree}') diff --git a/scripts/__tests__/go-release-artifact.test.mjs b/scripts/__tests__/go-release-artifact.test.mjs index 01d4122bf..8d4a217ab 100644 --- a/scripts/__tests__/go-release-artifact.test.mjs +++ b/scripts/__tests__/go-release-artifact.test.mjs @@ -1,6 +1,7 @@ import { execFileSync, spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -243,45 +244,82 @@ describe.skipIf(!BASH_HAS_MAPFILE)( * `build` reports the tree it tested and `publish` refuses any other, so the * two must stage the guests identically. Cargo writes them executable and * `publish` writes them 0644 from the artifact, which made the trees differ - * in mode alone: the first release refused itself. This runs the build job's - * staging, up to the tree it reports, on executable guests. + * in mode alone: the first release refused itself. Both now set the mode in + * git (`--chmod=-x`), so it no longer depends on the files. */ const STAGE_STEP = WORKFLOW.jobs.build.steps.find((step) => step.id === 'stage') const STAGE_UNTIL = 'fetched=$(mktemp -d)' +/** + * Run the build job's staging, up to the tree it reports, on guests left + * executable as cargo leaves them, then return to `main`. Returns the tree. + */ +function runBuildStaging() { + const script = String(STAGE_STEP.run) + const cut = script.indexOf(STAGE_UNTIL) + expect( + cut, + `the stage step no longer contains ${STAGE_UNTIL}`, + ).toBeGreaterThan(0) + for (const guest of GUESTS) { + const built = join(repo, guest) + mkdirSync(dirname(built), { recursive: true }) + writeFileSync(built, `guest ${guest}\n`, { mode: 0o755 }) + } + const result = spawnSync( + 'bash', + ['-c', `${script.slice(0, cut)}\necho "$tree"`], + { + cwd: repo, + encoding: 'utf8', + env: { + PATH: process.env.PATH, + HOME: repo, + GIT_CONFIG_NOSYSTEM: '1', + GUESTS: WORKFLOW.env.GUESTS, + VERSION: '0.1.0-alpha.1', + }, + }, + ) + expect(result.status, result.stderr).toBe(0) + // Forced: the guests on disk are still 0755 while the commit holds them + // 0644, which git reads as a local change. + git(repo, 'checkout', '-q', '-f', 'main') + return result.stdout.trim().split('\n').at(-1) +} + describe('release-golang.yml build: Stage the release commit', () => { - it('reports the tree publish builds, whatever mode cargo left the guests in', () => { + it('commits the guests non-executable, whatever mode cargo left them in', () => { expect(STAGE_STEP).toBeDefined() - const script = String(STAGE_STEP.run) - const cut = script.indexOf(STAGE_UNTIL) - expect( - cut, - `the stage step no longer contains ${STAGE_UNTIL}`, - ).toBeGreaterThan(0) - + const tree = runBuildStaging() for (const guest of GUESTS) { - stage(guest, `guest ${guest}\n`) - const built = join(repo, guest) - mkdirSync(dirname(built), { recursive: true }) - writeFileSync(built, `guest ${guest}\n`, { mode: 0o755 }) + expect(git(repo, 'ls-tree', tree, guest).split(/\s+/)[0], guest).toBe( + '100644', + ) } - const result = spawnSync( - 'bash', - ['-c', `${script.slice(0, cut)}\necho "$tree"`], - { - cwd: repo, - encoding: 'utf8', - env: { - PATH: process.env.PATH, - HOME: repo, - GIT_CONFIG_NOSYSTEM: '1', - GUESTS: WORKFLOW.env.GUESTS, - VERSION: '0.1.0-alpha.1', - }, - }, - ) - expect(result.status, result.stderr).toBe(0) - git(repo, 'checkout', '-q', 'main') - expect(result.stdout.trim().split('\n').at(-1)).toBe(expectedTree()) }) }) + +describe.skipIf(!BASH_HAS_MAPFILE)( + 'release-golang.yml build and publish build the same tree', + () => { + it.skipIf(!GNU_COREUTILS)( + 'publish accepts the tree build reports, from an executable artifact', + () => { + const tree = runBuildStaging() + // The artifact executable too: the two jobs agree through git, not + // through what either one wrote to disk. + writeManifest( + GUESTS.map((guest) => { + const line = stage(guest, `guest ${guest}\n`) + chmodSync(join(repo, 'release-out', guest), 0o755) + return line + }), + ) + const result = runCommitStep(tree) + expect(result.stderr + result.stdout).not.toContain('::error::') + expect(result.status).toBe(0) + }, + ) + }, +)