Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
46 changes: 46 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,52 @@ input in `copilot-setup-steps.yml` aligned with the compiler used to generate th
workflows and with `validate-agentic-workflows.yml`. Updating only the setup
action does not update the installed compiler; its `version` input controls that.

`recompile-agentic-workflows.yml` uses `pull_request: closed` to handle merged
upstream Dependabot PRs targeting the default branch, including grouped GitHub
Actions updates. Unmerged closures are skipped. It detects changed
`github/gh-aw-actions` pins from the PR diff and verifies their release comments
against upstream tag commits (or resolves an uncommented SHA through upstream
tags). It never selects a compiler from the PR title or the latest release.
It can also be run manually from the default branch using **Run workflow** or
`gh workflow run recompile-agentic-workflows.yml`. Manual dispatch selects the
newest release already pinned by `github/gh-aw-actions` references in the default
branch's workflow YAML, verifies those pins against upstream tags, and aligns both
compiler inputs before recompiling. It does not fetch the latest upstream release.
Manual runs reuse the `recompile-agentic-workflows-manual` branch and update its
open PR, refreshing its title and body to match the selected release; if there
are no changes, they open no PR.
The workflow checks out only the trusted default branch, skips superseded pins,
aligns both `setup-cli` pins and version inputs, and runs:

```shell
gh extension install github/gh-aw --pin <detected-version> --force
gh aw version
gh aw compile --no-check-update --schedule-seed microsoft/aspire
gh aw lint --shellcheck
```

It does not run `gh aw upgrade` or refresh authoring scaffolding. Compilation or
lint failures prevent publication, unexpected changed paths fail the run, and an
empty diff opens no PR. Aspirey creates or updates a stable branch per source PR;
reruns do not reopen a closed follow-up PR. Runs sharing a follow-up branch are
serialized: each source PR gets its own concurrency group, and manual dispatches
share a separate group. Unrelated PR closures cannot replace a queued gh-aw update;
a pending rerun can only be replaced by another run for the same source.
Version verification captures both stdout and stderr
because older gh-aw releases print their version to stderr.
The existing `ASPIRE_BOT_APP_ID` and `ASPIRE_BOT_PRIVATE_KEY` secrets are
required as both **Actions secrets** (manual runs) and **Dependabot secrets**
(Dependabot-triggered runs), using the same names. GitHub does not expose normal
Actions secrets to Dependabot-triggered `pull_request` workflows, even on a manual
rerun; see [GitHub's Dependabot secrets guidance](https://docs.github.com/en/code-security/reference/supply-chain-security/troubleshoot-dependabot/dependabot-on-actions#accessing-secrets).
The workflow explicitly grants `GITHUB_TOKEN` only Contents and Pull requests read
permissions; all writes use the separately minted App token. No privileged
`pull_request_target` trigger or dangerous-trigger suppression is needed.
The App installation must grant **Contents**, **Pull requests**, and
**Workflows** write permissions to push generated workflow YAML. Newly introduced
third-party action SHAs still require the repository/enterprise allow-list update
before the generated PR is merged.

Commit the generated `.lock.yml` files and `.github/aw/actions-lock.json` alongside
their source changes. `agentics-maintenance-microsoft-aspire.dev.yml` is generated
by the same full compilation despite not having a `.lock.yml` suffix. Do not edit
Expand Down
199 changes: 199 additions & 0 deletions .github/workflows/recompile-agentic-workflows.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
'use strict';

const fs = require('node:fs');
const { execFileSync } = require('node:child_process');

const compilerFiles = [
'.github/workflows/copilot-setup-steps.yml',
'.github/workflows/validate-agentic-workflows.yml',
];
const releasePattern = /^v\d+\.\d+\.\d+$/;

// Dependabot pins look like:
// + uses: github/gh-aw-actions/setup@<40-character SHA> # v0.89.17
// A missing release comment is resolved through upstream tags, never the PR title.
function actionReferences(text) {
return [...text.matchAll(/^\s*(?:-\s*)?uses:\s*github\/gh-aw-actions\/[\w/-]+@([a-f0-9]{40}|v\d+\.\d+\.\d+)(?:\s+#\s*(v\d+\.\d+\.\d+)\b)?\s*$/gm)]
.map(match => ({ ref: match[1], version: match[2] }));
}

async function resolveVersion(reference, github) {
const version = reference.version ?? (releasePattern.test(reference.ref) ? reference.ref : undefined);
if (version) {
const { data } = await github.rest.repos.getCommit({
owner: 'github', repo: 'gh-aw-actions', ref: version,
});
if (reference.ref !== version && data.sha !== reference.ref) {
throw new Error(`gh-aw action pin ${reference.ref} does not match ${version}.`);
}
return version;
}

const tags = await github.paginate(github.rest.repos.listTags, {
owner: 'github', repo: 'gh-aw-actions', per_page: 100,
});
const versions = tags.filter(tag => tag.commit.sha === reference.ref && releasePattern.test(tag.name));
if (versions.length !== 1) {
throw new Error(`Cannot uniquely map gh-aw action pin ${reference.ref} to a release.`);
}
return versions[0].name;
}

function synchronizeCompiler(content, version, sha) {
let count = 0;
const result = content.replace(
/(^[ \t]*uses: github\/gh-aw-actions\/setup-cli@)[^\r\n]+(\r?\n[ \t]*with:\r?\n[ \t]*version: )[^\r\n]+/gm,
(_, prefix, input) => {
count++;
return `${prefix}${sha} # ${version}${input}${version}`;
});
if (count !== 1) {
throw new Error('Expected exactly one setup-cli action with a version input.');
}
return result;
}

async function detect({ github, context, core }, io = fs) {
if (context.eventName === 'workflow_dispatch') {
if (context.repo.owner !== 'microsoft' || context.repo.repo !== 'aspire' ||
context.ref !== `refs/heads/${context.payload.repository.default_branch}`) {
throw new Error('Manual recompilation requires the upstream default branch.');
}
const references = new Map();
for (const file of io.readdirSync('.github/workflows', { recursive: true })) {
if (!/\.ya?ml$/.test(file)) {
continue;
}
for (const reference of actionReferences(io.readFileSync(`.github/workflows/${file}`, 'utf8'))) {
references.set(`${reference.ref}:${reference.version}`, reference);
}
}
const versions = [];
for (const reference of references.values()) {
versions.push(await resolveVersion(reference, github));
}
if (versions.length === 0) {
throw new Error('No pinned gh-aw action releases found on the default branch.');
}
const version = versions.sort(compareVersions).at(-1);
await prepareCompilation({
github, core, version, branch: 'recompile-agentic-workflows-manual',
source: 'the default branch action pins (manual dispatch)',
}, io);
return;
}
const pr = context.payload.pull_request;
if (context.eventName !== 'pull_request' || context.payload.action !== 'closed' ||
context.repo.owner !== 'microsoft' || context.repo.repo !== 'aspire' ||
!pr?.merged || pr.user.login !== 'dependabot[bot]' ||
pr.head.repo.full_name !== 'microsoft/aspire' ||
pr.base.ref !== context.payload.repository.default_branch) {
throw new Error('Recompilation requires a merged upstream Dependabot PR targeting the default branch.');
}

const branch = `recompile-agentic-workflows-${pr.number}`;
const existing = await github.paginate(github.rest.pulls.list, {
...context.repo, head: `${context.repo.owner}:${branch}`, state: 'all', per_page: 100,
});
if (existing.some(pull => pull.state === 'closed')) {
core.notice('The recompilation PR is already closed; not reopening it on a rerun.');
return;
}

const files = await github.paginate(github.rest.pulls.listFiles, {
...context.repo, pull_number: pr.number, per_page: 100,
});
const candidates = [];
for (const file of files.filter(file => /^\.github\/workflows\/.*\.ya?ml$/.test(file.filename) && file.status !== 'removed')) {
if (!file.patch) {
throw new Error(`Missing PR diff for ${file.filename}; cannot safely detect a gh-aw update.`);
}
const added = actionReferences(file.patch.split('\n').filter(line => line.startsWith('+')).map(line => line.slice(1)).join('\n'));
const removed = actionReferences(file.patch.split('\n').filter(line => line.startsWith('-')).map(line => line.slice(1)).join('\n'));
for (const reference of added.filter(reference => !removed.some(old => old.ref === reference.ref))) {
const current = actionReferences(io.readFileSync(file.filename, 'utf8'));
if (!current.some(item => item.ref === reference.ref)) {
core.notice('The merged gh-aw update has been superseded on the default branch.');
return;
}
if (!candidates.some(item => item.ref === reference.ref && item.version === reference.version)) {
candidates.push(reference);
}
}
}
if (candidates.length === 0) {
core.info('The merged PR did not change any gh-aw action pins.');
return;
}
const versions = new Set();
for (const reference of candidates) {
versions.add(await resolveVersion(reference, github));
}
if (versions.size !== 1) {
throw new Error('The merged PR contains conflicting gh-aw releases.');
}
const [version] = versions;
await prepareCompilation({ github, core, version, branch, source: `Dependabot PR #${pr.number}` }, io);
}

function compareVersions(left, right) {
const rightParts = right.slice(1).split('.').map(Number);
return left.slice(1).split('.').map(Number)
.map((part, index) => part - rightParts[index]).find(part => part !== 0) ?? 0;
}

async function prepareCompilation({ github, core, version, branch, source }, io) {
const compilerContents = compilerFiles.map(file => [file, io.readFileSync(file, 'utf8')]);
for (const [, content] of compilerContents) {
for (const reference of actionReferences(content)) {
const currentVersion = reference.version ?? reference.ref;
if (!releasePattern.test(currentVersion)) {
throw new Error('The current compiler pin must identify its release.');
}
if (compareVersions(currentVersion, version) > 0) {
core.notice('A newer compiler is already pinned on the default branch; not downgrading it.');
return;
}
}
}
const { data } = await github.rest.repos.getCommit({
owner: 'github', repo: 'gh-aw-actions', ref: version,
});
// Compute every edit first so an unexpected compiler-pin shape cannot leave
// a partially synchronized checkout.
const updates = compilerContents.map(([file, content]) => [file, synchronizeCompiler(content, version, data.sha)]);
for (const [file, content] of updates) {
io.writeFileSync(file, content);
}
core.setOutput('version', version);
core.setOutput('branch', branch);
core.setOutput('source', source);
core.setOutput('updated', 'true');
}

function allowedChange(file) {
return compilerFiles.includes(file) || file === '.github/aw/actions-lock.json' ||
/^\.github\/workflows\/(?:[^/]+\/)*[^/]+\.lock\.yml$/.test(file) ||
/^\.github\/workflows\/agentics-maintenance[^/]*\.yml$/.test(file);
}

function checkChanges(run = execFileSync, io = fs) {
const paths = [
...run('git', ['diff', '--name-only', '-z', 'HEAD'], { encoding: 'utf8' }).split('\0'),
...run('git', ['ls-files', '--others', '--exclude-standard', '-z'], { encoding: 'utf8' }).split('\0'),
].filter(Boolean);
const unexpected = paths.filter(file => !allowedChange(file));
if (unexpected.length) {
throw new Error(`Compilation changed unexpected files: ${unexpected.join(', ')}`);
}
io.appendFileSync(process.env.GITHUB_OUTPUT, `changed=${paths.length > 0}\n`);
}

module.exports = { actionReferences, resolveVersion, synchronizeCompiler, detect, allowedChange, checkChanges };

if (require.main === module) {
if (process.argv[2] !== 'changes') {
throw new Error('Expected the changes command.');
}
checkChanges();
}
124 changes: 124 additions & 0 deletions .github/workflows/recompile-agentic-workflows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
name: Recompile agentic workflows

on:
workflow_dispatch:
pull_request:
types: [closed]
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
IEvangelist marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without a paths filter, every PR closed in the repo starts a run of this workflow, only for the job-level if to skip it, which adds noise to the Actions history. detect only looks at .github/workflows/ YAML, so PRs that touch nothing there can't matter.

Suggested change
types: [closed]
types: [closed]
paths:
- '.github/workflows/**'


permissions:
contents: read
pull-requests: read

# Each source PR has its own follow-up branch; manual runs share a separate one.
# Serialize writers to the same branch without letting unrelated closures cancel
# a pending update. Replacing a pending rerun of the same source is safe.
concurrency:
group: recompile-agentic-workflows-${{ github.event.pull_request.number || 'manual' }}
cancel-in-progress: false
Comment thread
IEvangelist marked this conversation as resolved.
Resolved

jobs:
recompile:
if: >-
github.repository == 'microsoft/aspire' &&
((github.event_name == 'workflow_dispatch' &&
github.ref_name == github.event.repository.default_branch) ||
(github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.user.login == 'dependabot[bot]' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.base.ref == github.event.repository.default_branch))
runs-on: ubuntu-latest
steps:
# Only merged PRs run. Use the trusted default branch, including any fixes
# merged since the triggering PR, rather than executing the PR head.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false

- name: Select pinned gh-aw release
id: detect
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await require('./.github/workflows/recompile-agentic-workflows.js').detect({ github, context, core });

- name: Install exact gh-aw release and compile all workflows
if: steps.detect.outputs.updated == 'true'
env:
GH_TOKEN: ${{ github.token }}
GH_AW_VERSION: ${{ steps.detect.outputs.version }}
run: |
set -euo pipefail
gh extension install github/gh-aw --pin "$GH_AW_VERSION" --force
# Older gh-aw releases (including v0.89.17) print the version to stderr.
installed=$(gh aw version 2>&1)
printf '%s\n' "$installed"
[[ "$installed" == "gh aw version $GH_AW_VERSION" ]]
gh aw compile --no-check-update --schedule-seed microsoft/aspire
Comment thread
IEvangelist marked this conversation as resolved.
Resolved
gh aw lint --shellcheck

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing tells anyone when this job fails after a Dependabot merge, and that merge has already left main with setup-cli ahead of its version input. Until a follow-up PR lands, every non-Dependabot PR touching agentic inputs fails the strict drift check.

The failure is likely, not just possible. publication-guard.js targets v0.89.17 output and throws on anything it doesn't recognize, so a compiler bump that changes the output stops here with no PR. Missing Dependabot secrets would do the same at the token step. The only signal is a red run on a closed PR.

Please report the failure the way update-actionlint.yml does, with a failure() job calling report-pipeline-failure.js. A Dependabot-triggered run gets a read-only GITHUB_TOKEN, so filing the issue may need the App token. Fine as a follow-up.


- name: Check complete recompilation changes
if: steps.detect.outputs.updated == 'true'
id: changes
run: node .github/workflows/recompile-agentic-workflows.js changes

# Only mint the write token after compilation/lint and the path allowlist
# succeed. The App must also be granted Workflows write in its installation.
# Dependabot-triggered runs need these credentials in Dependabot secrets:
# https://docs.github.com/en/code-security/reference/supply-chain-security/troubleshoot-dependabot/dependabot-on-actions#accessing-secrets
- name: Generate Aspirey token
if: steps.changes.outputs.changed == 'true'
id: pr-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.ASPIRE_BOT_APP_ID }}
private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
permission-workflows: write

- name: Create or update recompilation PR
if: steps.changes.outputs.changed == 'true'
# actionlint does not yet support zizmor's preferred '$/' local-action syntax.
uses: ./.github/actions/create-pull-request # zizmor: ignore[self-repository]
with:
token: ${{ steps.pr-token.outputs.token }}
branch: ${{ steps.detect.outputs.branch }}
base: ${{ github.event.repository.default_branch }}
title: "[automated] Recompile agentic workflows with gh-aw ${{ steps.detect.outputs.version }}"
Comment thread
IEvangelist marked this conversation as resolved.
Resolved
commit-message: "[automated] Recompile agentic workflows with gh-aw ${{ steps.detect.outputs.version }}"
labels: area-engineering-systems
update-existing-pr-metadata: true
body: |
## Description

Recompiles all agentic workflows using the exact gh-aw release from ${{ steps.detect.outputs.source }} and aligns the Copilot and validation compiler pins.

Compiler: https://github.com/github/gh-aw/releases/tag/${{ steps.detect.outputs.version }}

Validation passed: `gh aw version`, `gh aw compile --no-check-update --schedule-seed microsoft/aspire`, and `gh aw lint --shellcheck`.

## Checklist

- Is this feature complete?
- [x] Yes. Ready to ship.
- [ ] No. Follow-up changes expected.
- Are you including unit tests for the changes and scenario tests if relevant?
- [ ] Yes
- [x] No
- Did you add public API?
- [ ] Yes
- If yes, did you have an API Review for it?
- [ ] Yes
- [ ] No
- Did you add `<remarks />` and `<code />` elements on your triple slash comments?
- [ ] Yes
- [ ] No
- [x] No
- Does the change make any security assumptions or guarantees?
- [x] Yes
- If yes, have you done a threat model and had a security review?
- [ ] Yes
- [ ] No
- [ ] No
Loading
Loading