diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2bc5ed..253390f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} @@ -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: @@ -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 diff --git a/.github/workflows/clawsweeper-dispatch.yml b/.github/workflows/clawsweeper-dispatch.yml index 5af378a..bf7bea0 100644 --- a/.github/workflows/clawsweeper-dispatch.yml +++ b/.github/workflows/clawsweeper-dispatch.yml @@ -12,33 +12,25 @@ permissions: contents: read concurrency: - group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} + group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event_name }}-${{ github.event.comment.id || github.event.issue.number || github.event.pull_request.number || github.run_id }}-${{ endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && github.actor || 'dispatchable' }} cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }} jobs: dispatch: runs-on: ubuntu-latest + # id-token proves this repository, event, branch, and run to the queue. + # The job never checks out or executes pull request code. + permissions: + contents: read + id-token: write if: ${{ !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }} env: - HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }} - CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093 SUPERSEDES_IN_PROGRESS: ${{ (github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review') && 'true' || 'false' }} steps: - name: Debounce bursty metadata events if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }} run: sleep 20 - - name: Create ClawSweeper dispatch token - id: token - if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }} - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }} - private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }} - owner: openclaw - repositories: clawsweeper - permission-contents: write - - name: Pre-filter ClawSweeper comment id: comment_filter if: ${{ github.event_name == 'issue_comment' }} @@ -46,157 +38,222 @@ jobs: COMMENT_BODY: ${{ github.event.comment.body }} run: | set -euo pipefail - if grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|autoclose|auto([[:space:]]+|-)?merge)\b' <<< "$COMMENT_BODY"; then + if [[ "$COMMENT_BODY" == *")' "$body_file"; then - echo "Ignoring ClawSweeper proof-nudge comment." - exit 0 - fi - if [ -n "$TARGET_TOKEN" ]; then - GH_TOKEN="$TARGET_TOKEN" gh api -X POST \ - -H "Accept: application/vnd.github+json" \ - "repos/$TARGET_REPO/issues/comments/$COMMENT_ID/reactions" \ - -f content="eyes" >/dev/null || true - fi - status_comment_id="" - if [ -n "$TARGET_TOKEN" ]; then - case "$AUTHOR_ASSOCIATION" in - OWNER|MEMBER|COLLABORATOR) - status_body="$(printf '%s\n' \ - "" \ - "🦞👀" \ - "ClawSweeper picked this up." \ - "" \ - "Command router queued. I will update this comment with the next step.")" - status_payload="$(jq -nc --arg body "$status_body" '{body:$body}')" - status_err="$(mktemp)" - if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh api \ - "repos/$TARGET_REPO/issues/$ITEM_NUMBER/comments" \ - --method POST \ - --input - <<< "$status_payload" 2>"$status_err")"; then - status_comment_id="$(jq -r '.id // empty' <<< "$status_response")" - else - cat "$status_err" >&2 - echo "::warning::Could not create ClawSweeper queued status comment; dispatching command router without one." - fi - rm -f "$status_err" - ;; - esac - fi - payload="$(jq -nc \ + set -euo pipefail + client_payload="$(jq -nc \ --arg target_repo "$TARGET_REPO" \ --argjson item_number "$ITEM_NUMBER" \ --argjson comment_id "$COMMENT_ID" \ - --arg status_comment_id "$status_comment_id" \ - --arg source_event "issue_comment" \ --arg source_action "$SOURCE_ACTION" \ - '{event_type:"clawsweeper_comment",client_payload:({target_repo:$target_repo,item_number:$item_number,comment_id:$comment_id,source_event:$source_event,source_action:$source_action,max_comments:"1"} + (if $status_comment_id != "" then {status_comment_id:($status_comment_id|tonumber)} else {} end))}')" - GH_TOKEN="$DISPATCH_TOKEN" gh api repos/openclaw/clawsweeper/dispatches \ - --method POST \ - --input - <<< "$payload" + '{target_repo:$target_repo,item_number:$item_number,comment_id:$comment_id,source_event:"issue_comment",source_action:$source_action}')" + # The App webhook remains an independent delivery route. + if [ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] && + [ -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] && + oidc_token="$(curl --fail --silent --show-error --connect-timeout 5 --max-time 15 \ + --header "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + --get --data-urlencode "audience=$TARGET_DISPATCH_URL" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL" | jq -er '.value | strings | select(length > 0)')" && + [ -n "$oidc_token" ]; then + echo "::add-mask::$oidc_token" + direct_body="$RUNNER_TEMP/clawsweeper-direct-intake.json" + if direct_status="$(curl --silent --show-error --connect-timeout 5 --max-time 20 \ + --request POST \ + --header "authorization: Bearer $oidc_token" \ + --header "content-type: application/json" \ + --data "$client_payload" \ + --output "$direct_body" \ + --write-out '%{http_code}' \ + "$TARGET_DISPATCH_URL")" && + [[ "$direct_status" == 2[0-9][0-9] ]]; then + echo "Queued ClawSweeper intake directly: $(jq -c . "$direct_body" 2>/dev/null || echo '{}')" + exit 0 + fi + # Only non-transient 4xx is a deliberate refusal; the App webhook covers it. + # Timeouts, throttling, and verifier/Worker failures must stay re-runnable. + if [[ "${direct_status:-}" == 4[0-9][0-9] && "$direct_status" != 408 && "$direct_status" != 429 ]]; then + echo "::warning::ClawSweeper intake declined this event (HTTP $direct_status); the App webhook route still applies." + exit 0 + fi + echo "::error::ClawSweeper intake unavailable (HTTP ${direct_status:-none}); re-run this job after the Worker recovers." + else + echo "::error::ClawSweeper OIDC token unavailable; the dispatch job needs id-token: write." + fi + exit 1 diff --git a/.github/workflows/dry-run-archetypes.yml b/.github/workflows/dry-run-archetypes.yml index b6d20e7..2a94b9f 100644 --- a/.github/workflows/dry-run-archetypes.yml +++ b/.github/workflows/dry-run-archetypes.yml @@ -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 diff --git a/.github/workflows/release-source.yml b/.github/workflows/release-source.yml index 4260387..e1f3bb2 100644 --- a/.github/workflows/release-source.yml +++ b/.github/workflows/release-source.yml @@ -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 @@ -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 @@ -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 @@ -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" @@ -855,6 +905,31 @@ 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 }}" @@ -862,10 +937,14 @@ jobs: 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'); @@ -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'); @@ -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'); @@ -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'); @@ -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'); diff --git a/CHANGELOG.md b/CHANGELOG.md index b6fa801..bf8913a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 3460198..9422919 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,12 @@ Use [`examples/release-source-caller.yml`](examples/release-source-caller.yml). Both new archetypes accept `require-signed-tag`, `strict-checks`, and `ci-check-events`; the default event filter is `["push"]`. They accept an existing annotated tag on protected-branch ancestry, or create one from protected main on a live dispatch. Set `require-signed-tag: true` to require an existing SSH-signed tag verified against `.github/release-allowed-signers` from the frozen source. Neither workflow moves version tags or overwrites releases. Dispatch callers must run on the protected default branch's current head; exact tag-push callers are also supported. A dry run defaults to true and never publishes. A dry-run dispatch on the protected default branch always validates and builds that exact branch head, even if the requested version already has a tag; it neither reads nor modifies that unrelated tag, and signed-tag verification is deferred to a live release. A tag-triggered dry run still validates the tag and its signature when required. +`generated-asset` optionally adds one caller-generated file to the same verified release payload. Its default is empty, preserving source-only callers. Set it to a safe basename such as `local-package-envelope.tar.gz`; `test-command` must write exactly that file to the workflow-provided `RELEASE_ASSET_PATH`. The fresh output directory must contain no other entries, including hidden files. The directory and file must not be symlinks, the file must be nonempty and under 2 GiB, and control/source archive names are reserved case-insensitively. The existing read-only build uploads the bytes; assembly downloads only that build's immutable artifact ID, and both independent verifiers require the requested file in the unchanged inventory and checksum format. The internal `tests-passed` marker is never released. + +For an already qualified artifact from the **same caller run**, also set `generated-input-artifact-id` to its single positive integer ID. The pinned download action places it in a fresh directory exposed as `RELEASE_ASSET_INPUT_DIR`, separate from the output. No ID means no download and no input variable. There are no cross-run, repository, token, name or pattern selectors. The caller's release job must depend on the producer and every required consumer succeeding; it passes the producer's exact ID, then validates the downloaded inputs against the frozen release checkout before writing `RELEASE_ASSET_PATH`. + +This is opaque byte transport, not package or runtime qualification. For example, a ClawSweeper caller must compare its four original envelope files, source commit/tree/clean diff, hashes and dependency lock against the actual release target, then wrap those unchanged files only after Linux, macOS and Windows consumed that same envelope. A new dependency resolution at the same commit does not inherit an older envelope's proof. An artifact from newer main must not qualify an older release tag. Shared checksum verification does not replace these caller checks, claim reproducible builds or enable publication. Leave `homebrew-formula` empty for generated packages: the existing source handoff still selects the GitHub source archive. + Optional `homebrew-formula` and `homebrew-tap` inputs dispatch `update-formula.yml` using `TAP_TOKEN`, a unique `request_id`, and the exact release-asset URL. The workflow waits for that run and checks the literal stable formula URL and SHA-256. Source releases require `source-archives: true`; npm handoff uses the last package in dependency order. This mode supports single-URL source/npm formulas, not platform-matrix binary formulas. The tap workflow must support `artifact_url` and `request_id` with the request ID in its run title, as the fleet taps do. See [RELEASING.md](RELEASING.md) for this repository's no-publish proof, release publication, and signed `v1` compatibility-tag update. @@ -382,7 +388,7 @@ scripts/smoke-release-build.sh The smoke check removes its temporary build directory and Go cache on exit. It does not sign, tag, or publish a release. -Validation and native keychain CI explicitly select Node 26.10.0; snapshot integration pins Go 1.27.1 and exercises the caller-configurable Electron tool defaults. Superseded pull-request runs are cancelled, while main runs complete. Swift's default Linux image pins the Swift 6.4.0 Noble manifest digest; callers can still override `swift-linux-image`. +Validation and native keychain CI explicitly select Node 26.10.0; snapshot integration pins Go 1.27.2 and exercises the caller-configurable Electron tool defaults. Superseded pull-request runs are cancelled, while main runs complete. Swift's default Linux image pins the Swift 6.4.0 Noble manifest digest; callers can still override `swift-linux-image`. With Docker available, `bash scripts/smoke-swift-toolchain.sh` compiles and runs a Linux executable with static Swift libraries inside that exact default image. CI runs the same proof alongside the Go snapshot integration. @@ -392,4 +398,4 @@ Literal comment markers in inline code spans or escaped Markdown do not start HT `node scripts/test-electron-signing.mjs` executes the production Electron unpacking and signing steps against synthetic apps, including unsafe archives, nested seals, independent DMG notarization, and failure cleanup. On macOS, `--native-packaging` also exercises real `ditto`/`hdiutil` packaging and versioned framework symlinks; Apple signing and notarization are simulated. -The dispatch-only `dry-run-archetypes.yml` exercises both new workflows on protected main. Its npm caller uses the committed synthetic monorepo, packs and installs both sibling packages on clean x64/arm64 runners, and runs the exported API assertion. `dry_run` is hardcoded true; no npm account or registry publication is involved. The source caller downloads and verifies frozen GitHub archives. Dispatch with the version matching the fixture and dated changelog. +The dispatch-only `dry-run-archetypes.yml` exercises both new workflows on protected main. Its npm caller uses the committed synthetic monorepo, packs and installs both sibling packages on clean x64/arm64 runners, and runs the exported API assertion. `dry_run` is hardcoded true; no npm account or registry publication is involved. The source caller downloads frozen GitHub archives and independently verifies a generated fixture archive alongside them. PR CI exercises both default and generated production blocks and a real same-run input/output artifact round trip; the full reusable-workflow rehearsal still requires protected main. Dispatch with the version matching the fixture and dated changelog. diff --git a/examples/release-source-caller.yml b/examples/release-source-caller.yml index d406035..4b3c0e6 100644 --- a/examples/release-source-caller.yml +++ b/examples/release-source-caller.yml @@ -27,6 +27,11 @@ jobs: test-runs-on: xcode-27 test-command: swift build -c release --jobs 2 && swift test --jobs 2 source-archives: true + # Optional: test-command writes one file to RELEASE_ASSET_PATH. + # generated-asset: local-package-envelope.tar.gz + # To consume qualified same-run input, depend on its producer and consumers: + # generated-input-artifact-id: ${{ needs.producer.outputs.artifact-id }} + # Validate RELEASE_ASSET_INPUT_DIR against the frozen checkout before wrapping. # Existing unprefixed catalogs: tag-prefix: '' (also change the push filter) # Optional branded title: release-name: Example Library # Go libraries: test-command: go test ./... and go-modules: '["."]' diff --git a/scripts/test-source-npm-workflows.mjs b/scripts/test-source-npm-workflows.mjs index 8bea753..1fe89e5 100644 --- a/scripts/test-source-npm-workflows.mjs +++ b/scripts/test-source-npm-workflows.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync, readFileSync, cpSync, mkdirSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, cpSync, mkdirSync, readdirSync, unlinkSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { loadWorkflow, workflowStep } from './workflow-source.cjs'; @@ -144,5 +144,100 @@ assert.ok(!JSON.stringify(npm.jobs['publish-npm']).includes('secrets.')); assert.match(step(npm,'publish-npm','Publish exact tarballs with trusted publishing').run,/10\*60\*1000/); assert.match(step(npm,'provenance','Verify npm signatures and provenance independently').run,/'audit','signatures'/); assert.match(step(npm,'provenance','Verify npm signatures and provenance independently').run,/gitCommit===process.env.TARGET_SHA/); + +const source=workflows[0]; +assert.equal((source.on??source.true).workflow_call.inputs['generated-asset'].default,''); +const rawDownload=step(source,'assemble','Download generated asset from this build'); +assert.equal(rawDownload.if,"inputs.generated-asset != ''"); +assert.equal(rawDownload.with['artifact-ids'],'${{ needs.build.outputs.artifact-id }}'); +assert.equal(rawDownload.with['digest-mismatch'],'error'); +assert.equal(rawDownload.with['github-token'],undefined); +const inputDownload=step(source,'build','Download generated input from this run'); +assert.equal(inputDownload.if,"inputs.generated-input-artifact-id != ''"); +assert.equal(inputDownload.with['artifact-ids'],'${{ inputs.generated-input-artifact-id }}'); +assert.equal(inputDownload.with['digest-mismatch'],'error'); +assert.deepEqual(Object.keys(inputDownload.with).sort(),['artifact-ids','digest-mismatch','path']); +assert.equal(step(source,'build','Prepare isolated generated input').if,inputDownload.if); +assert.equal((source.on??source.true).workflow_call.inputs['generated-input-artifact-id'].default,''); +const sourceEnv={...process.env,VERSION:'1.2.3',TAG_PREFIX:'v',TEST_RUNS_ON:'ubuntu-latest',HOMEBREW_TAP:'openclaw/homebrew-tap',HOMEBREW_FORMULA:'',GO_MODULES:'[]',TAG:'v1.2.3',RELEASE_VERSION:'1.2.3',GITHUB_REPOSITORY:'openclaw/fixture',TARGET_SHA:'a'.repeat(40),GENERATED_INPUT_ARTIFACT_ID:''}; +const generatedName='fixture-envelope.tar.gz'; +for(const name of ['../escape.tgz','.hidden.tgz','a/b.tgz','*.tgz','a.tgz\n','a.tgz ','SHA256SUMS','release-notes.md','PACKAGE-PLAN.JSON','tests-passed','Source-1.2.3.TAR.GZ','source-1.2.3.zip']) { + assert.throws(()=>run('/bin/bash',['-c',step(source,'validate','Validate inputs').run],{env:{...sourceEnv,SOURCE_ARCHIVES:'false',GENERATED_ASSET:name,GITHUB_OUTPUT:path.join(root,'invalid-output')}}),/Command failed/,`reject unsafe generated filename ${JSON.stringify(name)}`); +} +for(const id of ['0','01','-1','1.0','1e3','1,2','1junk',' 1','1\n','9007199254740992'])assert.throws(()=>run('/bin/bash',['-c',step(source,'validate','Validate inputs').run],{env:{...sourceEnv,SOURCE_ARCHIVES:'false',GENERATED_ASSET:generatedName,GENERATED_INPUT_ARTIFACT_ID:id,GITHUB_OUTPUT:path.join(root,'invalid-output')}}),/Command failed/,`reject invalid artifact ID ${JSON.stringify(id)}`); +assert.throws(()=>run('/bin/bash',['-c',step(source,'validate','Validate inputs').run],{env:{...sourceEnv,SOURCE_ARCHIVES:'false',GENERATED_ASSET:'',GENERATED_INPUT_ARTIFACT_ID:'123',GITHUB_OUTPUT:path.join(root,'invalid-output')}}),/Command failed/,'input requires generated output'); +const sourceFixture=(name=generatedName,archives=false)=>{ + const dir=mkdtempSync(path.join(root,'generated-')); + const payload=path.join(dir,'payload');mkdirSync(payload); + writeFileSync(path.join(payload,'RELEASE-NOTES.md'),'## 1.2.3 - 2026-10-19\n\n- Fixture.\n'); + if(archives)for(const suffix of ['tar.gz','zip'])writeFileSync(path.join(payload,`source-1.2.3.${suffix}`),`source ${suffix}\n`); + const env={...sourceEnv,RUNNER_TEMP:dir,PAYLOAD:payload,GENERATED_ASSET:name,SOURCE_ARCHIVES:String(archives),GITHUB_OUTPUT:path.join(dir,'output')}; + run('/bin/bash',['-c',step(source,'validate','Validate inputs').run],{env}); + return {dir,payload,env}; +}; +const buildSource=(fixture,command='printf "generated fixture\\n" > "$RELEASE_ASSET_PATH"')=>run('/bin/bash',['-c',step(source,'build','Run caller tests without write credentials').run],{env:{...fixture.env,TEST_COMMAND:command}}); +const acceptSource=fixture=>{ + cpSync(path.join(fixture.dir,'raw'),path.join(fixture.dir,'generated-raw'),{recursive:true}); + run('/bin/bash',['-c',step(source,'assemble','Accept only the requested generated asset').run],{env:fixture.env}); +}; +const freezeSource=fixture=>run('/bin/bash',['-c',step(source,'assemble','Freeze inventory and checksums').run],{env:fixture.env}); +const verifySource=fixture=>{ + for(const job of ['verify','verify-arm'])run('/bin/bash',['-c',step(source,job,'Independently verify frozen bytes').run],{env:fixture.env}); +}; +for(const name of ['',generatedName])for(const archives of [false,true]) { + const fixture=sourceFixture(name,archives); + buildSource(fixture,`test -z "\${RELEASE_ASSET_INPUT_DIR+x}"; ${name?'printf "generated fixture\\n" > "$RELEASE_ASSET_PATH"':'test -z "${RELEASE_ASSET_PATH+x}"'}`); + assert.equal(readFileSync(path.join(fixture.dir,'raw/tests-passed'),'utf8'),'verified\n'); + if(name)acceptSource(fixture); + else assert.deepEqual(readdirSync(path.join(fixture.dir,'raw')),['tests-passed']); + freezeSource(fixture);verifySource(fixture); + const inv=JSON.parse(readFileSync(path.join(fixture.payload,'ASSET-INVENTORY.json'))); + assert.deepEqual(Object.keys(inv),['repository','commit','tag','assets']); + assert.equal(inv.assets.length,1+(archives?2:0)+(name?1:0)); + assert.equal(inv.assets.some(asset=>asset.name==='tests-passed'),false); +} +{ + const fixture=sourceFixture();fixture.env.GENERATED_INPUT_ARTIFACT_ID='123'; + run('/bin/bash',['-c',step(source,'validate','Validate inputs').run],{env:fixture.env}); + run('/bin/bash',['-c',step(source,'build','Prepare isolated generated input').run],{env:fixture.env}); + writeFileSync(path.join(fixture.dir,'generated-input/original.txt'),'immutable producer bytes\n'); + buildSource(fixture,'cp "$RELEASE_ASSET_INPUT_DIR/original.txt" "$RELEASE_ASSET_PATH"'); + acceptSource(fixture);freezeSource(fixture);verifySource(fixture); + assert.equal(readFileSync(path.join(fixture.payload,generatedName),'utf8'),'immutable producer bytes\n'); + const absent=sourceFixture();absent.env.GENERATED_INPUT_ARTIFACT_ID='123'; + assert.throws(()=>buildSource(absent),/Command failed/,'missing artifact input fails before caller command'); +} +for(const [label,command] of [ + ['missing','true'], + ['empty','touch "$RELEASE_ASSET_PATH"'], + ['extra','touch "$RELEASE_ASSET_PATH" "$(dirname "$RELEASE_ASSET_PATH")/extra"'], + ['hidden','printf x > "$RELEASE_ASSET_PATH"; touch "$(dirname "$RELEASE_ASSET_PATH")/.hidden"'], + ['directory','mkdir "$RELEASE_ASSET_PATH"'], + ['symlink','ln -s /dev/null "$RELEASE_ASSET_PATH"'], + ['replaced parent','rmdir "$(dirname "$RELEASE_ASSET_PATH")"; mkdir "$RUNNER_TEMP/other"; ln -s "$RUNNER_TEMP/other" "$(dirname "$RELEASE_ASSET_PATH")"; printf x > "$RELEASE_ASSET_PATH"'], + ['oversize','node -e \'const f=require("fs");f.closeSync(f.openSync(process.env.RELEASE_ASSET_PATH,"w"));f.truncateSync(process.env.RELEASE_ASSET_PATH,2**31)\''], + ['command failure','printf x > "$RELEASE_ASSET_PATH"; exit 7'], +]) { + assert.throws(()=>buildSource(sourceFixture(),command),/Command failed/,`reject ${label} generated output`); +} +for(const mutation of ['marker','marker symlink','missing','extra','hidden','symlink','case collision']) { + const fixture=sourceFixture();buildSource(fixture); + const raw=path.join(fixture.dir,'raw'); + if(mutation==='marker')writeFileSync(path.join(raw,'tests-passed'),'failed\n'); + if(mutation==='marker symlink'){unlinkSync(path.join(raw,'tests-passed'));symlinkSync('/dev/null',path.join(raw,'tests-passed'));} + if(mutation==='missing')unlinkSync(path.join(raw,generatedName)); + if(mutation==='extra'||mutation==='hidden')writeFileSync(path.join(raw,mutation==='hidden'?'.hidden':'extra'),'unexpected'); + if(mutation==='symlink'){unlinkSync(path.join(raw,generatedName));symlinkSync('/dev/null',path.join(raw,generatedName));} + if(mutation==='case collision')writeFileSync(path.join(fixture.payload,generatedName.toUpperCase()),'do not replace'); + assert.throws(()=>acceptSource(fixture),/Command failed/,`reject raw ${mutation}`); +} +for(const mutation of ['modified','missing','unrequested']) { + const fixture=sourceFixture();buildSource(fixture);acceptSource(fixture);freezeSource(fixture); + if(mutation==='modified')writeFileSync(path.join(fixture.payload,generatedName),'changed'); + if(mutation==='missing')unlinkSync(path.join(fixture.payload,generatedName)); + if(mutation==='unrequested')fixture.env.GENERATED_ASSET='another.tgz'; + for(const job of ['verify','verify-arm'])assert.throws(()=>run('/bin/bash',['-c',step(source,job,'Independently verify frozen bytes').run],{env:fixture.env}),/Command failed/,`${job} rejects ${mutation} generated asset`); +} +console.log('source generated assets: default parity, exact output/transport, adversarial paths and both verifiers passed'); console.log('source/npm: dry-run tag isolation, real monorepo packs, exact pins, workspace rejection and dated metadata passed'); } finally {rmSync(root,{recursive:true,force:true});}