Skip to content

Commit 80819a8

Browse files
authored
Makes the release script publish to npm (#168)
I'm working to make Yarn Switch use the binaries from npm to avoid potential availability issues should we use our own endpoints and misconfigure cache etc. It'll also make it easier to guarantee immutability or some other security requirements like provenance. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces npm distribution of release artifacts and tightens npm/OIDC integration and provenance generation. > > - New GitHub Actions `npm` job: unpacks built artifacts, creates per-target npm packages (`@yarnpkg/yarn-<target>`), and publishes them with GitHub OIDC (env `id-token: write`) > - Build matrix adds `i686-unknown-linux-musl`; release step wiring adjusted > - `zpm` npm publish path fixes: proper scoped package encoding (`%2f`) and shared URL constructors > - OIDC improvements: new `get_id_token` with explicit `audience` (derived from registry host or `sigstore`), GitHub Actions ID token response uses `value`, and audience query param added > - Provenance fixes: correct `GITHUB_WORKFLOW_REF` split (`@`) and builder ID format; generate sigstore payload via fetched token > - Add `url` crate (with error variant) and bump `zpm-switch` to `6.0.0-rc.9` > - Lockfile updates for related deps (e.g., `url`, `idna`, `percent-encoding`) > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fdb5419. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 6080d6f commit 80819a8

File tree

10 files changed

+220
-78
lines changed

10 files changed

+220
-78
lines changed

.github/workflows/releases.yml

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ jobs:
5050
os: ubuntu-latest
5151
- target: aarch64-unknown-linux-musl
5252
os: ubuntu-latest
53+
- target: i686-unknown-linux-musl
54+
os: ubuntu-latest
5355
- target: aarch64-apple-darwin
5456
os: macos-latest
5557

@@ -79,7 +81,6 @@ jobs:
7981
yarn-${{matrix.target}}.zip
8082
8183
release:
82-
if: needs.check.outputs.needs-release == 'true'
8384
needs: [check, build]
8485

8586
name: 'Creating a release'
@@ -114,3 +115,138 @@ jobs:
114115
artifacts/*
115116
env:
116117
GH_TOKEN: ${{github.token}}
118+
119+
npm:
120+
needs: [check, build]
121+
122+
name: 'Publishing to npm'
123+
runs-on: ubuntu-latest
124+
125+
environment: releases
126+
127+
permissions:
128+
contents: read
129+
id-token: write
130+
131+
steps:
132+
- name: Checkout the repo
133+
uses: actions/checkout@v4
134+
135+
- name: Download artifacts
136+
uses: actions/download-artifact@v4
137+
with:
138+
path: artifacts
139+
merge-multiple: true
140+
141+
- name: Unpack the build of Yarn we just created
142+
uses: actions/github-script@v8
143+
env:
144+
VERSION: ${{needs.check.outputs.version}}
145+
with:
146+
script: |
147+
const cp = require(`child_process`);
148+
const fs = require(`fs`);
149+
const path = require(`path`);
150+
151+
const artifacts = fs.readdirSync(`artifacts`, {
152+
withFileTypes: true,
153+
});
154+
155+
function includes(str, search) {
156+
return str.split(/-/).includes(search);
157+
}
158+
159+
function findFirst(str, map) {
160+
return Object.entries(map).find(([, value]) => includes(str, value))[0];
161+
}
162+
163+
for (const artifact of artifacts) {
164+
if (!artifact.isFile() || !artifact.name.startsWith('yarn-'))
165+
continue;
166+
167+
const targetName = artifact.name.replace(`.zip`, ``);
168+
const destinationPath = path.join(artifact.parentPath, targetName);
169+
const sourcePath = path.join(artifact.parentPath, artifact.name);
170+
171+
cp.execFileSync(`unzip`, [`-d`, destinationPath, sourcePath]);
172+
173+
const ext = includes(targetName, `windows`) ? `.exe` : ``;
174+
175+
// We don't need to distribute Yarn Switch itself
176+
fs.unlinkSync(path.join(destinationPath, `yarn`));
177+
178+
// Rename the binary to `yarn`
179+
fs.renameSync(path.join(destinationPath, `yarn-bin${ext}`), path.join(destinationPath, `yarn${ext}`));
180+
181+
// Make it executable
182+
fs.chmodSync(path.join(destinationPath, `yarn${ext}`), 0o755);
183+
184+
const cpu = findFirst(targetName, {
185+
x64: `x86_64`,
186+
arm64: `aarch64`,
187+
ia32: `i686`,
188+
});
189+
190+
const os = findFirst(targetName, {
191+
linux: `linux`,
192+
darwin: `darwin`,
193+
});
194+
195+
fs.writeFileSync(path.join(destinationPath, `package.json`), `${JSON.stringify({
196+
name: `@yarnpkg/${targetName}`,
197+
version: process.env.VERSION,
198+
license: `GPL-3.0`,
199+
cpu: [cpu],
200+
os: [os],
201+
bin: {
202+
yarn: `yarn${ext}`,
203+
},
204+
repository: {
205+
type: `git`,
206+
url: `git+https://github.com/yarnpkg/zpm.git`,
207+
},
208+
}, null, 2)}\n`);
209+
210+
fs.writeFileSync(path.join(destinationPath, `yarn.lock`), ``);
211+
}
212+
213+
- name: Add the bin directory to the PATH
214+
run: |
215+
echo "$PWD/artifacts/yarn-x86_64-unknown-linux-musl" >> $GITHUB_PATH
216+
217+
- name: Generate the packages
218+
uses: actions/github-script@v8
219+
env:
220+
YARN_NPM_AUTH_TOKEN: ${{secrets.TEMPORARY_NPM_PUBLISH_TOKEN}}
221+
with:
222+
script: |
223+
const cp = require(`child_process`);
224+
const fs = require(`fs`);
225+
const path = require(`path`);
226+
227+
const artifacts = fs.readdirSync(`artifacts`, {
228+
withFileTypes: true,
229+
});
230+
231+
for (const artifact of artifacts) {
232+
if (!artifact.isDirectory() || !artifact.name.startsWith('yarn-'))
233+
continue;
234+
235+
const artifactPath = path.join(artifact.parentPath, artifact.name);
236+
console.log(`Publishing ${artifactPath}`);
237+
238+
cp.execFileSync(`yarn`, [`install`], {
239+
stdio: `inherit`,
240+
cwd: artifactPath,
241+
});
242+
243+
cp.execFileSync(`yarn`, [`npm`, `whoami`], {
244+
stdio: `inherit`,
245+
cwd: artifactPath,
246+
});
247+
248+
cp.execFileSync(`yarn`, [`npm`, `publish`, `--access`, `public`], {
249+
stdio: `inherit`,
250+
cwd: artifactPath,
251+
});
252+
}

Cargo.lock

Lines changed: 11 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/zpm-switch/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "zpm-switch"
3-
version = "6.0.0-rc.8"
3+
version = "6.0.0-rc.9"
44
edition = "2021"
55

66
[[bin]]

packages/zpm/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,4 @@ hex = "0.4.3"
5151
p256 = "0.13.2"
5252
spki = "0.7.3"
5353
ring = "0.17.14"
54+
url = "2.5.7"

packages/zpm/src/commands/npm/publish.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use zpm_parsers::{JsonDocument, json_provider};
88
use zpm_utils::{IoResultExt, Provider, Sha1, Sha512, ToFileString, ToHumanString, is_ci};
99

1010
use crate::{
11-
error::Error, http::HttpClient, http_npm::{self, AuthorizationMode, NpmHttpParams}, npm, pack::{PackOptions, pack_workspace}, project::Project, provenance::attest, script::ScriptEnvironment
11+
error::Error, http::HttpClient, http_npm::{self, AuthorizationMode, GetIdTokenOptions, NpmHttpParams}, npm, pack::{PackOptions, pack_workspace}, project::Project, provenance::attest, script::ScriptEnvironment
1212
};
1313

1414
#[zpm_enum(or_else = |s| Err(Error::InvalidNpmPublishAccess(s.to_string())))]
@@ -182,14 +182,14 @@ impl Publish {
182182
digest: provenance_digest,
183183
};
184184

185-
let oidc_token
186-
= authorization.as_deref()
187-
.ok_or(Error::ProvenanceRequiresAuthentication)?
188-
.strip_prefix("Bearer ")
189-
.ok_or(Error::ProvenanceRequiresAuthentication)?;
185+
let sigstore_token
186+
= http_npm::get_id_token(&GetIdTokenOptions {
187+
http_client: &project.http_client,
188+
audience: "sigstore",
189+
}).await?;
190190

191191
let provenance_payload
192-
= create_provenance_payload(&project.http_client, &provenance_file, &oidc_token).await?;
192+
= create_provenance_payload(&project.http_client, &provenance_file, &sigstore_token.unwrap()).await?;
193193

194194
if let Some(provenance_payload) = provenance_payload {
195195
attachments.insert(
@@ -545,7 +545,7 @@ fn create_github_provenance_payload(subject: &ProvenanceSubject) -> Result<Strin
545545
= std::env::var("GITHUB_SERVER_URL")?;
546546

547547
let (workflow_path, workflow_ref)
548-
= github_workflow_ref.split_once('/')
548+
= github_workflow_ref.split_once('@')
549549
.unwrap_or_else(|| panic!("Expected workflow path and ref to both exist (got '{}' instead)", github_workflow_ref));
550550

551551
let workflow_repository
@@ -581,7 +581,7 @@ fn create_github_provenance_payload(subject: &ProvenanceSubject) -> Result<Strin
581581
},
582582
run_details: GitHubProvenanceRunDetails {
583583
builder: GitHubProvenanceBuilder {
584-
id: format!("{}{}", GITHUB_BUILDER_ID_PREFIX, std::env::var("RUNNER_ENVIRONMENT")?),
584+
id: format!("{}/{}", GITHUB_BUILDER_ID_PREFIX, std::env::var("RUNNER_ENVIRONMENT")?),
585585
},
586586
metadata: GitHubProvenanceMetadata {
587587
invocation_id: format!("{}/{}/actions/runs/{}/attempts/{}", std::env::var("GITHUB_SERVER_URL")?, std::env::var("GITHUB_REPOSITORY")?, std::env::var("GITHUB_RUN_ID")?, std::env::var("GITHUB_RUN_ATTEMPT")?),

packages/zpm/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ pub enum Error {
141141
#[error("Semver error ({0})")]
142142
SemverError(#[from] zpm_semver::Error),
143143

144+
#[error("URL error ({0})")]
145+
UrlError(#[from] url::ParseError),
146+
144147
#[error("Invalid ident ({0})")]
145148
InvalidIdent(String),
146149

packages/zpm/src/http_npm.rs

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,23 @@ pub fn should_authenticate(options: &GetAuthorizationOptions<'_>) -> bool {
129129
}
130130
}
131131

132-
async fn get_id_token(options: &GetAuthorizationOptions<'_>) -> Result<Option<String>, Error> {
132+
pub struct GetIdTokenOptions<'a> {
133+
pub http_client: &'a HttpClient,
134+
pub audience: &'a str,
135+
}
136+
137+
fn get_npm_audience(registry: &str) -> Result<String, Error> {
138+
let registry_url
139+
= url::Url::parse(registry)?;
140+
141+
let registry_host
142+
= registry_url.host_str()
143+
.expect("\"http:\" URL should have a host");
144+
145+
Ok(format!("npm:{}", registry_host))
146+
}
147+
148+
pub async fn get_id_token(options: &GetIdTokenOptions<'_>) -> Result<Option<String>, Error> {
133149
if let Ok(oidc_token) = std::env::var("NPM_ID_TOKEN") {
134150
return Ok(Some(oidc_token));
135151
}
@@ -142,6 +158,12 @@ async fn get_id_token(options: &GetAuthorizationOptions<'_>) -> Result<Option<St
142158
return Ok(None);
143159
};
144160

161+
let mut actions_id_token_request_url
162+
= url::Url::parse(&actions_id_token_request_url)?;
163+
164+
actions_id_token_request_url.query_pairs_mut()
165+
.append_pair("audience", options.audience);
166+
145167
let response
146168
= options.http_client.get(actions_id_token_request_url)?
147169
.header("authorization", Some(format!("Bearer {}", actions_id_token_request_token)))
@@ -153,13 +175,13 @@ async fn get_id_token(options: &GetAuthorizationOptions<'_>) -> Result<Option<St
153175

154176
#[derive(Deserialize)]
155177
struct ActionsIdTokenResponse {
156-
token: String,
178+
value: String,
157179
}
158180

159181
let data: ActionsIdTokenResponse
160182
= JsonDocument::hydrate_from_str(&body)?;
161183

162-
Ok(Some(data.token))
184+
Ok(Some(data.value))
163185
}
164186

165187
fn get_ident_url(ident: &Ident) -> String {
@@ -179,7 +201,10 @@ async fn get_oidc_token(options: &GetAuthorizationOptions<'_>) -> Result<Option<
179201
};
180202

181203
let id_token
182-
= get_id_token(options).await?;
204+
= get_id_token(&GetIdTokenOptions {
205+
http_client: options.http_client,
206+
audience: &get_npm_audience(&options.registry)?,
207+
}).await?;
183208

184209
let Some(id_token) = id_token else {
185210
return Ok(None);

0 commit comments

Comments
 (0)