Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
32 changes: 32 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,38 @@ 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` handles merged upstream Dependabot PRs targeting
the default branch, including grouped GitHub Actions updates. 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; 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 are serialized to avoid competing
updates. The existing `ASPIRE_BOT_APP_ID` and `ASPIRE_BOT_PRIVATE_KEY` secrets are
required. 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
198 changes: 198 additions & 0 deletions .github/workflows/recompile-agentic-workflows.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
'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.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();
}
118 changes: 118 additions & 0 deletions .github/workflows/recompile-agentic-workflows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
name: Recompile agentic workflows

on:
workflow_dispatch:
pull_request_target:
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

# Serialize updates from grouped Dependabot PRs and reruns of the same merge.
concurrency:
group: recompile-agentic-workflows
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_target' &&
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:
# Never execute the PR head with pull_request_target privileges. Use the
# current default branch, including any fixes merged since the triggering PR.
- 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
installed=$(gh aw version)
Comment thread
IEvangelist marked this conversation as resolved.
Resolved
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.
- 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
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