Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ jobs:
- name: Build and run with the default Swift Linux image
run: bash scripts/smoke-swift-toolchain.sh
- name: Upload named payload
id: input-upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: action-integration-${{ github.run_id }}-${{ github.run_attempt }}
Expand All @@ -88,8 +89,35 @@ jobs:
- name: Download named payload
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: action-integration-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/action-integration
artifact-ids: ${{ steps.input-upload.outputs.artifact-id }}
path: ${{ runner.temp }}/generated-input
digest-mismatch: error
- name: Build generated asset using the production source step
env:
GENERATED_ASSET: fixture-source.txt
GENERATED_INPUT_ARTIFACT_ID: ${{ steps.input-upload.outputs.artifact-id }}
TEST_COMMAND: cp "$RELEASE_ASSET_INPUT_DIR/README.md" "$RELEASE_ASSET_PATH"
run: |
node <<'JS'
const {execFileSync}=require('node:child_process');
const {loadWorkflow,workflowStep}=require('./scripts/workflow-source.cjs');
const step=workflowStep(loadWorkflow('release-source.yml'),'build','name','Run caller tests without write credentials');
execFileSync('/bin/bash',['-c',step.run],{env:process.env,stdio:'inherit'});
JS
- name: Upload generated raw payload
id: generated-upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: generated-integration-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/raw
if-no-files-found: error
retention-days: 1
- name: Download generated raw payload
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
artifact-ids: ${{ steps.generated-upload.outputs.artifact-id }}
path: ${{ runner.temp }}/generated-raw
digest-mismatch: error
- name: Verify bytes and GitHub client
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
Expand All @@ -98,12 +126,23 @@ jobs:
const fs = require('node:fs');
const path = require('node:path');
assert.deepEqual(
fs.readFileSync(path.join(process.env.RUNNER_TEMP, 'action-integration', 'README.md')),
fs.readFileSync(path.join(process.env.RUNNER_TEMP, 'generated-input', 'README.md')),
fs.readFileSync('README.md'),
);
const {execFileSync}=require('node:child_process');
const {loadWorkflow,workflowStep}=require('./scripts/workflow-source.cjs');
const workflow=loadWorkflow('release-source.yml');
const payload=path.join(process.env.RUNNER_TEMP,'payload');
fs.mkdirSync(payload);
fs.writeFileSync(path.join(payload,'RELEASE-NOTES.md'),'Integration fixture notes.\n');
const env={...process.env,PAYLOAD:payload,GENERATED_ASSET:'fixture-source.txt',SOURCE_ARCHIVES:'false',RELEASE_VERSION:'1.2.3',TAG:'integration',TARGET_SHA:process.env.GITHUB_SHA};
for(const [job,name] of [['assemble','Accept only the requested generated asset'],['assemble','Freeze inventory and checksums'],['verify','Independently verify frozen bytes'],['verify-arm','Independently verify frozen bytes']]) {
execFileSync('/bin/bash',['-c',workflowStep(workflow,job,'name',name).run],{env,stdio:'inherit'});
}
assert.deepEqual(fs.readFileSync(path.join(payload,'fixture-source.txt')),fs.readFileSync('README.md'));
const { data } = await github.rest.repos.get(context.repo);
assert.equal(data.full_name, process.env.GITHUB_REPOSITORY);
core.info('artifact round-trip bytes and github-script REST client passed');
core.info('same-run input/output artifact bytes, production source verifiers and github-script REST client passed');

swift-keychain-lifecycle:
runs-on: xcode-27
Expand Down
289 changes: 173 additions & 116 deletions .github/workflows/clawsweeper-dispatch.yml

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion .github/workflows/dry-run-archetypes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,7 @@ jobs:
version: ${{ inputs.version }}
dry_run: true
source-archives: true
test-command: bash -n scripts/validate-workflows.sh
generated-asset: source-fixture.tar.gz
test-command: |
bash -n scripts/validate-workflows.sh
tar -czf "$RELEASE_ASSET_PATH" README.md
93 changes: 91 additions & 2 deletions .github/workflows/release-source.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ name: Release source
required: false
default: ''
type: string
generated-asset:
description: Optional single release filename written by test-command to RELEASE_ASSET_PATH
required: false
default: ''
type: string
generated-input-artifact-id:
description: Optional same-run artifact consumed by test-command through RELEASE_ASSET_INPUT_DIR
required: false
default: ''
type: string
homebrew-tap:
description: Optional tap owner/repo
required: false
Expand Down Expand Up @@ -110,6 +120,8 @@ jobs:
GO_MODULES: "${{ inputs.go-modules }}"
HOMEBREW_TAP: "${{ inputs.homebrew-tap }}"
HOMEBREW_FORMULA: "${{ inputs.homebrew-formula }}"
GENERATED_ASSET: "${{ inputs.generated-asset }}"
GENERATED_INPUT_ARTIFACT_ID: "${{ inputs.generated-input-artifact-id }}"
run: |
set -euo pipefail
case "${TAG_PREFIX-v}" in
Expand All @@ -127,9 +139,14 @@ jobs:
[[ "$HOMEBREW_TAP" =~ ^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$ ]] || exit 1
[[ -z "$HOMEBREW_FORMULA" || "$HOMEBREW_FORMULA" =~ ^[A-Za-z0-9][A-Za-z0-9+_.@-]*$ ]] || exit 1
python3 - "$v" <<'SEMVER'
import sys
import os, re, sys
pre=sys.argv[1].partition('-')[2]
if any(x.isdigit() and len(x)>1 and x.startswith('0') for x in pre.split('.')): raise SystemExit('numeric prerelease identifiers cannot have leading zeros')
name=os.environ.get('GENERATED_ASSET', '')
reserved={'asset-inventory.json','sha256sums','release-notes.md','tests-passed','package-plan.json',f'source-{sys.argv[1]}.tar.gz'.lower(),f'source-{sys.argv[1]}.zip'.lower()}
if name and (not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9]',name) or name.lower() in reserved): raise SystemExit('generated-asset must be a safe, non-reserved release filename')
artifact_id=os.environ.get('GENERATED_INPUT_ARTIFACT_ID','')
if artifact_id and (not name or not re.fullmatch(r'[1-9][0-9]*',artifact_id) or int(artifact_id)>9007199254740991): raise SystemExit('generated-input-artifact-id requires generated-asset and one positive safe integer')
SEMVER
printf 'release-version=%s\ntag=%s%s\n' "$v" "${TAG_PREFIX-v}" "$v" >> "$GITHUB_OUTPUT"
- name: Resolve protected release target
Expand Down Expand Up @@ -807,15 +824,48 @@ jobs:
with:
ref: "${{ needs.validate.outputs.target-sha }}"
persist-credentials: false
- name: Prepare isolated generated input
if: inputs.generated-input-artifact-id != ''
run: mkdir "$RUNNER_TEMP/generated-input"
- name: Download generated input from this run
if: inputs.generated-input-artifact-id != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
path: "${{ runner.temp }}/generated-input"
artifact-ids: "${{ inputs.generated-input-artifact-id }}"
digest-mismatch: error
- name: Run caller tests without write credentials
env:
TEST_COMMAND: "${{ inputs.test-command }}"
GENERATED_ASSET: "${{ inputs.generated-asset }}"
GENERATED_INPUT_ARTIFACT_ID: "${{ inputs.generated-input-artifact-id }}"
run: |-
set -euo pipefail
unset NODE_AUTH_TOKEN NPM_TOKEN GH_TOKEN GITHUB_TOKEN
unset RELEASE_ASSET_PATH RELEASE_ASSET_INPUT_DIR
if [[ -n "${GENERATED_INPUT_ARTIFACT_ID:-}" ]]; then
node -e 'const fs=require("node:fs"),path=require("node:path"); if(!fs.lstatSync(path.join(process.env.RUNNER_TEMP,"generated-input")).isDirectory()) throw new Error("generated input must be a real directory")'
export RELEASE_ASSET_INPUT_DIR="$RUNNER_TEMP/generated-input"
fi
if [[ -n "${GENERATED_ASSET:-}" ]]; then
mkdir "$RUNNER_TEMP/generated"
export RELEASE_ASSET_PATH="$RUNNER_TEMP/generated/$GENERATED_ASSET"
fi
bash -e -o pipefail -c "$TEST_COMMAND"
mkdir "$RUNNER_TEMP/raw"
echo verified > "$RUNNER_TEMP/raw/tests-passed"
if [[ -n "${GENERATED_ASSET:-}" ]]; then
node <<'JS'
const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict');
const dir=path.join(process.env.RUNNER_TEMP,'generated'),name=process.env.GENERATED_ASSET;
// Inspect before upload: artifact transport omits hidden files and normalizes modes.
assert.ok(fs.lstatSync(dir).isDirectory(),'generated output must remain a real directory');
assert.deepEqual(fs.readdirSync(dir),[name],'generated output must contain only the requested asset');
const file=path.join(dir,name),stat=fs.lstatSync(file);
assert.ok(stat.isFile() && stat.size>0 && stat.size<2**31,'generated asset must be a nonempty regular file under 2 GiB');
fs.copyFileSync(file,path.join(process.env.RUNNER_TEMP,'raw',name),fs.constants.COPYFILE_EXCL);
JS
fi
- id: artifact
run: echo "name=source-raw-${{ needs.validate.outputs.tag }}-${{ github.run_id
}}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -855,17 +905,46 @@ jobs:
set -euo pipefail
gh api "repos/$GITHUB_REPOSITORY/tarball/$TARGET_SHA" > "$RUNNER_TEMP/payload/source-$RELEASE_VERSION.tar.gz"
gh api "repos/$GITHUB_REPOSITORY/zipball/$TARGET_SHA" > "$RUNNER_TEMP/payload/source-$RELEASE_VERSION.zip"
- name: Download generated asset from this build
if: inputs.generated-asset != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
path: "${{ runner.temp }}/generated-raw"
artifact-ids: "${{ needs.build.outputs.artifact-id }}"
digest-mismatch: error
- name: Accept only the requested generated asset
if: inputs.generated-asset != ''
env:
GENERATED_ASSET: "${{ inputs.generated-asset }}"
run: |
node <<'JS'
const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict');
const dir=path.join(process.env.RUNNER_TEMP,'generated-raw'),name=process.env.GENERATED_ASSET;
assert.ok(fs.lstatSync(dir).isDirectory(),'raw output must be a real directory');
assert.deepEqual(fs.readdirSync(dir).sort(),[name,'tests-passed'].sort(),'unexpected raw asset set');
const marker=path.join(dir,'tests-passed');
assert.ok(fs.lstatSync(marker).isFile(),'test marker must be a regular file');
assert.equal(fs.readFileSync(marker,'utf8'),'verified\n','test marker must record success');
const file=path.join(dir,name),stat=fs.lstatSync(file),payload=path.join(process.env.RUNNER_TEMP,'payload');
assert.ok(stat.isFile() && stat.size>0 && stat.size<2**31,'generated asset must be a nonempty regular file under 2 GiB');
assert.ok(!fs.readdirSync(payload).some(existing=>existing.toLowerCase()===name.toLowerCase()),'generated asset collides with release payload');
fs.copyFileSync(file,path.join(payload,name),fs.constants.COPYFILE_EXCL);
JS
- name: Freeze inventory and checksums
env:
TAG: "${{ needs.validate.outputs.tag }}"
TARGET_SHA: "${{ needs.validate.outputs.target-sha }}"
RELEASE_VERSION: "${{ needs.validate.outputs.release-version }}"
EXPECTED_TAG_OBJECT: "${{ needs.tag.outputs.tag-object-sha }}"
PAYLOAD: "${{ runner.temp }}/payload"
GENERATED_ASSET: "${{ inputs.generated-asset }}"
SOURCE_ARCHIVES: "${{ inputs.source-archives }}"
run: |-
node <<'JS'
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto');
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto'),assert=require('node:assert/strict');
const dir=process.env.PAYLOAD;
const expected=['RELEASE-NOTES.md',...(process.env.SOURCE_ARCHIVES==='true'?[`source-${process.env.RELEASE_VERSION}.tar.gz`,`source-${process.env.RELEASE_VERSION}.zip`]:[]),...(process.env.GENERATED_ASSET?[process.env.GENERATED_ASSET]:[])].sort();
assert.deepEqual(fs.readdirSync(dir).sort(),expected,'unexpected release asset set');
const hash=b=>crypto.createHash('sha256').update(b).digest('hex');
const assets=fs.readdirSync(dir).sort().map(name=>{
if(!/^[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9]$/.test(name) || !fs.lstatSync(path.join(dir,name)).isFile()) throw new Error('unsafe asset');
Expand Down Expand Up @@ -908,6 +987,8 @@ jobs:
TARGET_SHA: "${{ needs.validate.outputs.target-sha }}"
RELEASE_VERSION: "${{ needs.validate.outputs.release-version }}"
PAYLOAD: "${{ runner.temp }}/payload"
GENERATED_ASSET: "${{ inputs.generated-asset }}"
SOURCE_ARCHIVES: "${{ inputs.source-archives }}"
run: |
node <<'JS'
const fs=require('node:fs'), path=require('node:path'), crypto=require('node:crypto'), assert=require('node:assert/strict');
Expand All @@ -916,12 +997,15 @@ jobs:
const inventoryBytes=fs.readFileSync(path.join(dir,'ASSET-INVENTORY.json'));
const inv=JSON.parse(inventoryBytes);
assert.equal(inv.repository,process.env.GITHUB_REPOSITORY); assert.equal(inv.commit,process.env.TARGET_SHA); assert.equal(inv.tag,process.env.TAG);
const expected=['RELEASE-NOTES.md',...(process.env.SOURCE_ARCHIVES==='true'?[`source-${process.env.RELEASE_VERSION}.tar.gz`,`source-${process.env.RELEASE_VERSION}.zip`]:[]),...(process.env.GENERATED_ASSET?[process.env.GENERATED_ASSET]:[])].sort();
assert.deepEqual(inv.assets.map(a=>a.name).sort(),expected,'unexpected release asset set');
assert.deepEqual(fs.readdirSync(dir).sort(),[...inv.assets.map(a=>a.name),'ASSET-INVENTORY.json','SHA256SUMS'].sort());
assert.equal(new Set(inv.assets.map(a=>a.name.toLowerCase())).size,inv.assets.length);
for (const a of inv.assets) {
assert.match(a.name,/^[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9]$/);
const p=path.join(dir,a.name); assert.ok(fs.lstatSync(p).isFile());
const b=fs.readFileSync(p); assert.equal(b.length,a.size); assert.equal(hash(b),a.sha256);
if(a.name===process.env.GENERATED_ASSET) assert.ok(b.length>0 && b.length<2**31,'invalid generated asset size');
}
const sums=fs.readFileSync(path.join(dir,'SHA256SUMS'),'utf8');
assert.equal(sums,inv.assets.map(a=>a.sha256+' '+a.name+'\n').join('')+hash(inventoryBytes)+' ASSET-INVENTORY.json\n');
Expand Down Expand Up @@ -1252,6 +1336,8 @@ jobs:
TARGET_SHA: "${{ needs.validate.outputs.target-sha }}"
RELEASE_VERSION: "${{ needs.validate.outputs.release-version }}"
PAYLOAD: "${{ runner.temp }}/payload"
GENERATED_ASSET: "${{ inputs.generated-asset }}"
SOURCE_ARCHIVES: "${{ inputs.source-archives }}"
run: |
node <<'JS'
const fs=require('node:fs'), path=require('node:path'), crypto=require('node:crypto'), assert=require('node:assert/strict');
Expand All @@ -1260,12 +1346,15 @@ jobs:
const inventoryBytes=fs.readFileSync(path.join(dir,'ASSET-INVENTORY.json'));
const inv=JSON.parse(inventoryBytes);
assert.equal(inv.repository,process.env.GITHUB_REPOSITORY); assert.equal(inv.commit,process.env.TARGET_SHA); assert.equal(inv.tag,process.env.TAG);
const expected=['RELEASE-NOTES.md',...(process.env.SOURCE_ARCHIVES==='true'?[`source-${process.env.RELEASE_VERSION}.tar.gz`,`source-${process.env.RELEASE_VERSION}.zip`]:[]),...(process.env.GENERATED_ASSET?[process.env.GENERATED_ASSET]:[])].sort();
assert.deepEqual(inv.assets.map(a=>a.name).sort(),expected,'unexpected release asset set');
assert.deepEqual(fs.readdirSync(dir).sort(),[...inv.assets.map(a=>a.name),'ASSET-INVENTORY.json','SHA256SUMS'].sort());
assert.equal(new Set(inv.assets.map(a=>a.name.toLowerCase())).size,inv.assets.length);
for (const a of inv.assets) {
assert.match(a.name,/^[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9]$/);
const p=path.join(dir,a.name); assert.ok(fs.lstatSync(p).isFile());
const b=fs.readFileSync(p); assert.equal(b.length,a.size); assert.equal(hash(b),a.sha256);
if(a.name===process.env.GENERATED_ASSET) assert.ok(b.length>0 && b.length<2**31,'invalid generated asset size');
}
const sums=fs.readFileSync(path.join(dir,'SHA256SUMS'),'utf8');
assert.equal(sums,inv.assets.map(a=>a.sha256+' '+a.name+'\n').join('')+hash(inventoryBytes)+' ASSET-INVENTORY.json\n');
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Support one caller-generated source release asset, optionally consuming a qualified same-run artifact, through the existing read-only build, immutable artifact transport and independent checksum verification. Preserve source-only defaults and leave package qualification and Homebrew activation with callers; thanks @vincentkoc.
- Remove target-side App credentials from ClawSweeper dispatch; use OIDC intake with independent webhook delivery and retryable transient failures.

## 1.11.8 - 2026-10-18

- Trust only the exact Swift CLI Linux container checkout before dependency preparation so Git ownership checks do not block the release lockfile check.
Expand Down
Loading
Loading