diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 2faa865..4d0ce99 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -2,6 +2,8 @@ name: macOS release to Hetzner on: push: + # Gitea enforces the configured macos-v* protection when tags are pushed. + # Reading /tag_protections requires admin rights unavailable to job tokens. tags: - "macos-v*" workflow_dispatch: @@ -69,24 +71,6 @@ jobs: with: node-version: 22 - - name: Require protected macOS release tags - shell: bash - env: - GITEA_TOKEN: ${{ github.token }} - run: | - set -Eeuo pipefail - curl --fail --silent --show-error \ - --header "Authorization: Bearer $GITEA_TOKEN" \ - "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/tag_protections" \ - --output "$RUNNER_TEMP/macos-tag-protections.json" - node --input-type=module - <<'JS' - import { readFileSync } from 'node:fs'; - const rules = JSON.parse(readFileSync(`${process.env.RUNNER_TEMP}/macos-tag-protections.json`, 'utf8')); - if (!rules.some((rule) => rule.name_pattern === 'macos-v*')) { - throw new Error('Configure a protected macos-v* tag rule in Gitea before publishing.'); - } - JS - - name: Validate app versions and Hetzner configuration env: RELEASE_TAG: ${{ steps.ref.outputs.tag }} diff --git a/native/tests/macos-workflow.test.js b/native/tests/macos-workflow.test.js new file mode 100644 index 0000000..687cdaa --- /dev/null +++ b/native/tests/macos-workflow.test.js @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; +import test from 'node:test'; + +const workflow = readFileSync( + new URL('../../.github/workflows/macos-release.yml', import.meta.url), + 'utf8', +); +const selectRef = workflow + .split( + ' - name: Select an existing version tag contained in main\n', + )[1] + .split('\n - name:')[0] + .split(' run: |\n')[1] + .split('\n') + .map((line) => line.replace(/^ {10}/, '')) + .join('\n'); + +function fixture(t) { + const directory = mkdtempSync(join(tmpdir(), 'kanbangit-release-ref-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const remote = join(directory, 'remote'); + const checkout = join(directory, 'checkout'); + mkdirSync(remote); + const env = { + ...process.env, + GIT_AUTHOR_NAME: 'Release test', + GIT_COMMITTER_NAME: 'Release test', + GIT_AUTHOR_EMAIL: 'release@example.invalid', + GIT_COMMITTER_EMAIL: 'release@example.invalid', + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + }; + const git = (cwd, ...args) => { + const result = spawnSync('git', args, { cwd, env, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + git(remote, 'init', '-b', 'main'); + writeFileSync(join(remote, 'app.txt'), 'release'); + git(remote, 'add', 'app.txt'); + git(remote, '-c', 'commit.gpgsign=false', 'commit', '-m', 'Release'); + const commit = git(remote, 'rev-parse', 'HEAD'); + git(remote, 'tag', 'macos-v1.2.3'); + git(directory, 'clone', remote, checkout); + const output = join(directory, 'outputs'); + const run = (overrides = {}) => { + writeFileSync(output, ''); + const result = spawnSync( + 'bash', + ['--noprofile', '--norc', '-c', selectRef], + { + cwd: checkout, + encoding: 'utf8', + env: { + ...env, + GITHUB_EVENT_NAME: 'push', + GITHUB_REF: 'refs/tags/macos-v1.2.3', + REQUESTED_TAG: '', + MACOS_RUNNER: 'macos-local', + GITHUB_OUTPUT: output, + ...overrides, + }, + }, + ); + return { ...result, output: readFileSync(output, 'utf8') }; + }; + return { run, git, checkout, commit }; +} + +test('release selection succeeds using Git alone without an administrative API token', (t) => { + const f = fixture(t); + const result = f.run({ + GITEA_TOKEN: '', + GITHUB_TOKEN: '', + GITHUB_API_URL: 'http://127.0.0.1:1', + }); + assert.equal(result.status, 0, result.stderr); + assert.equal( + result.output, + `commit=${f.commit}\ntag=macos-v1.2.3\nrunner=macos-local\n`, + ); + assert.equal(f.git(f.checkout, 'rev-parse', 'HEAD'), f.commit); +}); + +test('manual release on main accepts an existing tag without moving it', (t) => { + const f = fixture(t); + const result = f.run({ + GITHUB_EVENT_NAME: 'workflow_dispatch', + GITHUB_REF: 'refs/heads/main', + REQUESTED_TAG: 'macos-v1.2.3', + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit); +}); + +for (const [name, overrides] of [ + ['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }], + ['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }], + [ + 'shell characters in tag', + { GITHUB_REF: 'refs/tags/macos-v1.2.3;exit 0' }, + ], + ['missing runner label', { MACOS_RUNNER: '' }], + [ + 'output injection in runner label', + { MACOS_RUNNER: 'macos-local\ncommit=other' }, + ], + [ + 'manual release outside main', + { + GITHUB_EVENT_NAME: 'workflow_dispatch', + GITHUB_REF: 'refs/heads/feature', + REQUESTED_TAG: 'macos-v1.2.3', + }, + ], +]) { + test(`release selection rejects ${name} before producing job outputs`, (t) => { + const result = fixture(t).run(overrides); + assert.notEqual(result.status, 0); + assert.equal(result.output, ''); + }); +} + +test('release selection rejects a tag whose commit is outside main', (t) => { + const f = fixture(t); + f.git(f.checkout, 'switch', '-c', 'unmerged'); + f.git( + f.checkout, + '-c', + 'commit.gpgsign=false', + 'commit', + '--allow-empty', + '-m', + 'Unmerged', + ); + f.git(f.checkout, 'tag', 'macos-v1.2.4'); + const result = f.run({ GITHUB_REF: 'refs/tags/macos-v1.2.4' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Release tag must belong to main/); + assert.equal(result.output, ''); +});