Add callable workflow to bump the version of an extension (#43566)

Finn Evers created

This adds an intial workflow file that can be pulled in to create a bump
commit for an extension version in an extension repository.

Release Notes:

- N/A

Change summary

.github/workflows/extension_bump.yml                 | 136 ++++++++
tooling/xtask/src/tasks/workflows.rs                 |   2 
tooling/xtask/src/tasks/workflows/cherry_pick.rs     |  22 
tooling/xtask/src/tasks/workflows/compare_perf.rs    |  18 
tooling/xtask/src/tasks/workflows/extension_bump.rs  | 217 ++++++++++++++
tooling/xtask/src/tasks/workflows/extension_tests.rs |   4 
tooling/xtask/src/tasks/workflows/run_agent_evals.rs |  10 
tooling/xtask/src/tasks/workflows/steps.rs           |  12 
tooling/xtask/src/tasks/workflows/vars.rs            |  97 +++++
9 files changed, 485 insertions(+), 33 deletions(-)

Detailed changes

.github/workflows/extension_bump.yml 🔗

@@ -0,0 +1,136 @@
+# Generated from xtask::workflows::extension_bump
+# Rebuild with `cargo xtask workflows`.
+name: extension_bump
+env:
+  CARGO_TERM_COLOR: always
+  RUST_BACKTRACE: '1'
+  CARGO_INCREMENTAL: '0'
+  ZED_EXTENSION_CLI_SHA: 7cfce605704d41ca247e3f84804bf323f6c6caaf
+on:
+  workflow_call:
+    inputs:
+      bump-type:
+        description: bump-type
+        type: string
+        default: patch
+    secrets:
+      app-id:
+        description: The app ID used to create the PR
+        required: true
+      app-secret:
+        description: The app secret for the corresponding app ID
+        required: true
+jobs:
+  check_extension:
+    if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
+    runs-on: namespace-profile-2x4-ubuntu-2404
+    steps:
+    - name: steps::checkout_repo
+      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      with:
+        clean: false
+    - id: cache-zed-extension-cli
+      name: extension_tests::cache_zed_extension_cli
+      uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
+      with:
+        path: zed-extension
+        key: zed-extension-${{ env.ZED_EXTENSION_CLI_SHA }}
+    - name: extension_tests::download_zed_extension_cli
+      if: steps.cache-zed-extension-cli.outputs.cache-hit != 'true'
+      run: |
+        wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension"
+        chmod +x zed-extension
+      shell: bash -euxo pipefail {0}
+    - name: extension_tests::check
+      run: |
+        mkdir -p /tmp/ext-scratch
+        mkdir -p /tmp/ext-output
+        ./zed-extension --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output
+      shell: bash -euxo pipefail {0}
+    timeout-minutes: 1
+  check_bump_needed:
+    if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
+    runs-on: namespace-profile-2x4-ubuntu-2404
+    steps:
+    - name: steps::checkout_repo
+      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      with:
+        clean: false
+        fetch-depth: 10
+    - id: compare-versions-check
+      name: extension_bump::compare_versions
+      run: |+
+        CURRENT_VERSION="$(sed -n 's/version = \"\(.*\)\"/\1/p' < extension.toml)"
+
+        git checkout "$(git log -1 --format=%H)"~1
+
+        PREV_COMMIT_VERSION="$(sed -n 's/version = \"\(.*\)\"/\1/p' < extension.toml)"
+
+        [[ "$CURRENT_VERSION" == "$PREV_COMMIT_VERSION" ]] && \
+          echo "needs_bump=true" >> "$GITHUB_OUTPUT" || \
+          echo "needs_bump=false" >> "$GITHUB_OUTPUT"
+
+      shell: bash -euxo pipefail {0}
+    outputs:
+      needs_bump: ${{ steps.compare-versions-check.outputs.needs_bump }}
+    timeout-minutes: 1
+  bump_extension_version:
+    needs:
+    - check_extension
+    - check_bump_needed
+    if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && needs.check_bump_needed.outputs.needs_bump == 'true'
+    runs-on: namespace-profile-8x16-ubuntu-2204
+    steps:
+    - id: generate-token
+      name: extension_bump::generate_token
+      uses: actions/create-github-app-token@v2
+      with:
+        app-id: ${{ secrets.app-id }}
+        private-key: ${{ secrets.app-secret }}
+    - name: steps::checkout_repo
+      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      with:
+        clean: false
+    - name: extension_bump::install_bump_2_version
+      run: pip install bump2version
+      shell: bash -euxo pipefail {0}
+    - id: bump-version
+      name: extension_bump::bump_version
+      run: |
+        OLD_VERSION="$(sed -n 's/version = \"\(.*\)\"/\1/p' < extension.toml)"
+
+        cat <<EOF > .bumpversion.cfg
+        [bumpversion]
+        current_version = "$OLD_VERSION"
+
+        [bumpversion:file:Cargo.toml]
+
+        [bumpversion:file:extension.toml]
+
+        EOF
+
+        bump2version --verbose ${{ inputs.bump-type }}
+        NEW_VERSION="$(sed -n 's/version = \"\(.*\)\"/\1/p' < extension.toml)"
+        cargo update --workspace
+
+        rm .bumpversion.cfg
+
+        echo "old_version=${OLD_VERSION}" >> "$GITHUB_OUTPUT"
+        echo "new_version=${NEW_VERSION}" >> "$GITHUB_OUTPUT"
+      shell: bash -euxo pipefail {0}
+    - name: extension_bump::create_pull_request
+      uses: peter-evans/create-pull-request@v7
+      with:
+        title: Bump version to ${{ steps.bump-version.outputs.new_version }}
+        body: This PR bumps the version of this extension to v${{ steps.bump-version.outputs.new_version }}
+        commit-message: Bump version to v${{ steps.bump-version.outputs.new_version }}
+        branch: bump-from-${{ steps.bump-version.outputs.old_version }}
+        committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
+        base: main
+        delete-branch: true
+        token: ${{ steps.generate-token.outputs.token }}
+        sign-commits: true
+    timeout-minutes: 1
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
+  cancel-in-progress: true

tooling/xtask/src/tasks/workflows.rs 🔗

@@ -7,6 +7,7 @@ mod after_release;
 mod cherry_pick;
 mod compare_perf;
 mod danger;
+mod extension_bump;
 mod extension_tests;
 mod nix_build;
 mod release_nightly;
@@ -44,6 +45,7 @@ pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> {
         ("run_agent_evals.yml", run_agent_evals::run_agent_evals()),
         ("after_release.yml", after_release::after_release()),
         ("extension_tests.yml", extension_tests::extension_tests()),
+        ("extension_bump.yml", extension_bump::extension_bump()),
     ];
     fs::create_dir_all(dir)
         .with_context(|| format!("Failed to create directory: {}", dir.display()))?;

tooling/xtask/src/tasks/workflows/cherry_pick.rs 🔗

@@ -3,14 +3,14 @@ use gh_workflow::*;
 use crate::tasks::workflows::{
     runners,
     steps::{self, NamedJob, named},
-    vars::{self, Input, StepOutput},
+    vars::{self, StepOutput, WorkflowInput},
 };
 
 pub fn cherry_pick() -> Workflow {
-    let branch = Input::string("branch", None);
-    let commit = Input::string("commit", None);
-    let channel = Input::string("channel", None);
-    let pr_number = Input::string("pr_number", None);
+    let branch = WorkflowInput::string("branch", None);
+    let commit = WorkflowInput::string("commit", None);
+    let channel = WorkflowInput::string("channel", None);
+    let pr_number = WorkflowInput::string("pr_number", None);
     let cherry_pick = run_cherry_pick(&branch, &commit, &channel);
     named::workflow()
         .run_name(format!("cherry_pick to {channel} #{pr_number}"))
@@ -24,7 +24,11 @@ pub fn cherry_pick() -> Workflow {
         .add_job(cherry_pick.name, cherry_pick.job)
 }
 
-fn run_cherry_pick(branch: &Input, commit: &Input, channel: &Input) -> NamedJob {
+fn run_cherry_pick(
+    branch: &WorkflowInput,
+    commit: &WorkflowInput,
+    channel: &WorkflowInput,
+) -> NamedJob {
     fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
         let step = named::uses(
             "actions",
@@ -39,9 +43,9 @@ fn run_cherry_pick(branch: &Input, commit: &Input, channel: &Input) -> NamedJob
     }
 
     fn cherry_pick(
-        branch: &Input,
-        commit: &Input,
-        channel: &Input,
+        branch: &WorkflowInput,
+        commit: &WorkflowInput,
+        channel: &WorkflowInput,
         token: &StepOutput,
     ) -> Step<Run> {
         named::bash(&format!("./script/cherry-pick {branch} {commit} {channel}"))

tooling/xtask/src/tasks/workflows/compare_perf.rs 🔗

@@ -5,13 +5,13 @@ use crate::tasks::workflows::steps::FluentBuilder;
 use crate::tasks::workflows::{
     runners,
     steps::{self, NamedJob, named},
-    vars::Input,
+    vars::WorkflowInput,
 };
 
 pub fn compare_perf() -> Workflow {
-    let head = Input::string("head", None);
-    let base = Input::string("base", None);
-    let crate_name = Input::string("crate_name", Some("".to_owned()));
+    let head = WorkflowInput::string("head", None);
+    let base = WorkflowInput::string("base", None);
+    let crate_name = WorkflowInput::string("crate_name", Some("".to_owned()));
     let run_perf = run_perf(&base, &head, &crate_name);
     named::workflow()
         .on(Event::default().workflow_dispatch(
@@ -23,8 +23,12 @@ pub fn compare_perf() -> Workflow {
         .add_job(run_perf.name, run_perf.job)
 }
 
-pub fn run_perf(base: &Input, head: &Input, crate_name: &Input) -> NamedJob {
-    fn cargo_perf_test(ref_name: &Input, crate_name: &Input) -> Step<Run> {
+pub fn run_perf(
+    base: &WorkflowInput,
+    head: &WorkflowInput,
+    crate_name: &WorkflowInput,
+) -> NamedJob {
+    fn cargo_perf_test(ref_name: &WorkflowInput, crate_name: &WorkflowInput) -> Step<Run> {
         named::bash(&format!(
             "
             if [ -n \"{crate_name}\" ]; then
@@ -39,7 +43,7 @@ pub fn run_perf(base: &Input, head: &Input, crate_name: &Input) -> NamedJob {
         named::uses("taiki-e", "install-action", "hyperfine")
     }
 
-    fn compare_runs(head: &Input, base: &Input) -> Step<Run> {
+    fn compare_runs(head: &WorkflowInput, base: &WorkflowInput) -> Step<Run> {
         named::bash(&format!(
             "cargo perf-compare --save=results.md {base} {head}"
         ))

tooling/xtask/src/tasks/workflows/extension_bump.rs 🔗

@@ -0,0 +1,217 @@
+use gh_workflow::*;
+use indoc::indoc;
+
+use crate::tasks::workflows::{
+    extension_tests::{self},
+    runners,
+    steps::{self, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, named},
+    vars::{
+        JobOutput, StepOutput, WorkflowInput, WorkflowSecret, one_workflow_per_non_main_branch,
+    },
+};
+
+const BUMPVERSION_CONFIG: &str = indoc! {r#"
+    [bumpversion]
+    current_version = "$OLD_VERSION"
+
+    [bumpversion:file:Cargo.toml]
+
+    [bumpversion:file:extension.toml]
+    "#
+};
+
+const VERSION_CHECK: &str = r#"sed -n 's/version = \"\(.*\)\"/\1/p' < extension.toml"#;
+
+// This is used by various extensions repos in the zed-extensions org to bump extension versions.
+pub(crate) fn extension_bump() -> Workflow {
+    let bump_type = WorkflowInput::string("bump-type", Some("patch".to_owned()));
+
+    let app_id = WorkflowSecret::new("app-id", "The app ID used to create the PR");
+    let app_secret =
+        WorkflowSecret::new("app-secret", "The app secret for the corresponding app ID");
+
+    let test_extension = extension_tests::check_extension();
+    let (check_bump_needed, needs_bump) = check_bump_needed();
+    let bump_version = bump_extension_version(
+        &[&test_extension, &check_bump_needed],
+        &bump_type,
+        needs_bump.as_job_output(&check_bump_needed),
+        &app_id,
+        &app_secret,
+    );
+
+    named::workflow()
+        .add_event(
+            Event::default().workflow_call(
+                WorkflowCall::default()
+                    .add_input(bump_type.name, bump_type.call_input())
+                    .secrets([
+                        (app_id.name.to_owned(), app_id.secret_configuration()),
+                        (
+                            app_secret.name.to_owned(),
+                            app_secret.secret_configuration(),
+                        ),
+                    ]),
+            ),
+        )
+        .concurrency(one_workflow_per_non_main_branch())
+        .add_env(("CARGO_TERM_COLOR", "always"))
+        .add_env(("RUST_BACKTRACE", 1))
+        .add_env(("CARGO_INCREMENTAL", 0))
+        .add_env((
+            "ZED_EXTENSION_CLI_SHA",
+            extension_tests::ZED_EXTENSION_CLI_SHA,
+        ))
+        .add_job(test_extension.name, test_extension.job)
+        .add_job(check_bump_needed.name, check_bump_needed.job)
+        .add_job(bump_version.name, bump_version.job)
+}
+
+fn check_bump_needed() -> (NamedJob, StepOutput) {
+    let (compare_versions, version_changed) = compare_versions();
+
+    let job = Job::default()
+        .with_repository_owner_guard()
+        .outputs([(version_changed.name.to_owned(), version_changed.to_string())])
+        .runs_on(runners::LINUX_SMALL)
+        .timeout_minutes(1u32)
+        .add_step(steps::checkout_repo().add_with(("fetch-depth", 10)))
+        .add_step(compare_versions);
+
+    (named::job(job), version_changed)
+}
+
+/// Compares the current and previous commit and checks whether versions changed inbetween.
+fn compare_versions() -> (Step<Run>, StepOutput) {
+    let check_needs_bump = named::bash(format!(
+        indoc! {
+            r#"
+        CURRENT_VERSION="$({})"
+
+        git checkout "$(git log -1 --format=%H)"~1
+
+        PREV_COMMIT_VERSION="$({})"
+
+        [[ "$CURRENT_VERSION" == "$PREV_COMMIT_VERSION" ]] && \
+          echo "needs_bump=true" >> "$GITHUB_OUTPUT" || \
+          echo "needs_bump=false" >> "$GITHUB_OUTPUT"
+
+        "#
+        },
+        VERSION_CHECK, VERSION_CHECK
+    ))
+    .id("compare-versions-check");
+
+    let needs_bump = StepOutput::new(&check_needs_bump, "needs_bump");
+
+    (check_needs_bump, needs_bump)
+}
+
+fn bump_extension_version(
+    dependencies: &[&NamedJob],
+    bump_type: &WorkflowInput,
+    needs_bump: JobOutput,
+    app_id: &WorkflowSecret,
+    app_secret: &WorkflowSecret,
+) -> NamedJob {
+    let (generate_token, generated_token) = generate_token(app_id, app_secret);
+    let (bump_version, old_version, new_version) = bump_version(bump_type);
+
+    let job = steps::dependant_job(dependencies)
+        .cond(Expression::new(format!(
+            "{DEFAULT_REPOSITORY_OWNER_GUARD} && {} == 'true'",
+            needs_bump.expr(),
+        )))
+        .runs_on(runners::LINUX_LARGE)
+        .timeout_minutes(1u32)
+        .add_step(generate_token)
+        .add_step(steps::checkout_repo())
+        .add_step(install_bump_2_version())
+        .add_step(bump_version)
+        .add_step(create_pull_request(
+            old_version,
+            new_version,
+            generated_token,
+        ));
+
+    named::job(job)
+}
+
+fn generate_token(app_id: &WorkflowSecret, app_secret: &WorkflowSecret) -> (Step<Use>, StepOutput) {
+    let step = named::uses("actions", "create-github-app-token", "v2")
+        .id("generate-token")
+        .add_with(
+            Input::default()
+                .add("app-id", app_id.to_string())
+                .add("private-key", app_secret.to_string()),
+        );
+
+    let generated_token = StepOutput::new(&step, "token");
+
+    (step, generated_token)
+}
+
+fn install_bump_2_version() -> Step<Run> {
+    named::run(runners::Platform::Linux, "pip install bump2version")
+}
+
+fn bump_version(bump_type: &WorkflowInput) -> (Step<Run>, StepOutput, StepOutput) {
+    let step = named::bash(format!(
+        indoc! {r#"
+            OLD_VERSION="$({})"
+
+            cat <<EOF > .bumpversion.cfg
+            {}
+            EOF
+
+            bump2version --verbose {}
+            NEW_VERSION="$({})"
+            cargo update --workspace
+
+            rm .bumpversion.cfg
+
+            echo "old_version=${{OLD_VERSION}}" >> "$GITHUB_OUTPUT"
+            echo "new_version=${{NEW_VERSION}}" >> "$GITHUB_OUTPUT"
+            "#
+        },
+        VERSION_CHECK, BUMPVERSION_CONFIG, bump_type, VERSION_CHECK
+    ))
+    .id("bump-version");
+
+    let old_version = StepOutput::new(&step, "old_version");
+    let new_version = StepOutput::new(&step, "new_version");
+    (step, old_version, new_version)
+}
+
+fn create_pull_request(
+    old_version: StepOutput,
+    new_version: StepOutput,
+    generated_token: StepOutput,
+) -> Step<Use> {
+    let formatted_version = format!("v{}", new_version);
+
+    named::uses("peter-evans", "create-pull-request", "v7").with(
+        Input::default()
+            .add("title", format!("Bump version to {}", new_version))
+            .add(
+                "body",
+                format!(
+                    "This PR bumps the version of this extension to {}",
+                    formatted_version
+                ),
+            )
+            .add(
+                "commit-message",
+                format!("Bump version to {}", formatted_version),
+            )
+            .add("branch", format!("bump-from-{}", old_version))
+            .add(
+                "committer",
+                "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
+            )
+            .add("base", "main")
+            .add("delete-branch", true)
+            .add("token", generated_token.to_string())
+            .add("sign-commits", true),
+    )
+}

tooling/xtask/src/tasks/workflows/extension_tests.rs 🔗

@@ -9,7 +9,7 @@ use crate::tasks::workflows::{
 };
 
 const RUN_TESTS_INPUT: &str = "run_tests";
-const ZED_EXTENSION_CLI_SHA: &str = "7cfce605704d41ca247e3f84804bf323f6c6caaf";
+pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "7cfce605704d41ca247e3f84804bf323f6c6caaf";
 
 // This is used by various extensions repos in the zed-extensions org to run automated tests.
 pub(crate) fn extension_tests() -> Workflow {
@@ -77,7 +77,7 @@ fn check_rust() -> NamedJob {
     named::job(job)
 }
 
-fn check_extension() -> NamedJob {
+pub(crate) fn check_extension() -> NamedJob {
     let (cache_download, cache_hit) = cache_zed_extension_cli();
     let job = Job::default()
         .with_repository_owner_guard()

tooling/xtask/src/tasks/workflows/run_agent_evals.rs 🔗

@@ -3,12 +3,12 @@ use gh_workflow::{Event, Expression, Job, Run, Schedule, Step, Use, Workflow, Wo
 use crate::tasks::workflows::{
     runners::{self, Platform},
     steps::{self, FluentBuilder as _, NamedJob, named, setup_cargo_config},
-    vars::{self, Input},
+    vars::{self, WorkflowInput},
 };
 
 pub(crate) fn run_agent_evals() -> Workflow {
     let agent_evals = agent_evals();
-    let model_name = Input::string("model_name", None);
+    let model_name = WorkflowInput::string("model_name", None);
 
     named::workflow()
         .on(Event::default().workflow_dispatch(
@@ -29,8 +29,8 @@ pub(crate) fn run_agent_evals() -> Workflow {
 }
 
 pub(crate) fn run_unit_evals() -> Workflow {
-    let model_name = Input::string("model_name", None);
-    let commit_sha = Input::string("commit_sha", None);
+    let model_name = WorkflowInput::string("model_name", None);
+    let commit_sha = WorkflowInput::string("commit_sha", None);
 
     let unit_evals = named::job(unit_evals(Some(&commit_sha)));
 
@@ -117,7 +117,7 @@ fn cron_unit_evals() -> NamedJob {
     named::job(unit_evals(None).add_step(send_failure_to_slack()))
 }
 
-fn unit_evals(commit: Option<&Input>) -> Job {
+fn unit_evals(commit: Option<&WorkflowInput>) -> Job {
     let script_step = add_api_keys(steps::script("./script/run-unit-evals"));
 
     Job::default()

tooling/xtask/src/tasks/workflows/steps.rs 🔗

@@ -142,9 +142,13 @@ pub struct NamedJob {
 //     }
 // }
 
+pub(crate) const DEFAULT_REPOSITORY_OWNER_GUARD: &str =
+    "(github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')";
+
 pub fn repository_owner_guard_expression(trigger_always: bool) -> Expression {
     Expression::new(format!(
-        "(github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions'){}",
+        "{}{}",
+        DEFAULT_REPOSITORY_OWNER_GUARD,
         trigger_always.then_some(" && always()").unwrap_or_default()
     ))
 }
@@ -248,8 +252,10 @@ pub mod named {
     /// Returns a bash-script step with the same name as the enclosing function.
     /// (You shouldn't inline this function into the workflow definition, you must
     /// wrap it in a new function.)
-    pub fn bash(script: &str) -> Step<Run> {
-        Step::new(function_name(1)).run(script).shell(BASH_SHELL)
+    pub fn bash(script: impl AsRef<str>) -> Step<Run> {
+        Step::new(function_name(1))
+            .run(script.as_ref())
+            .shell(BASH_SHELL)
     }
 
     /// Returns a pwsh-script step with the same name as the enclosing function.

tooling/xtask/src/tasks/workflows/vars.rs 🔗

@@ -1,6 +1,9 @@
 use std::cell::RefCell;
 
-use gh_workflow::{Concurrency, Env, Expression, Step, WorkflowDispatchInput};
+use gh_workflow::{
+    Concurrency, Env, Expression, Step, WorkflowCallInput, WorkflowCallSecret,
+    WorkflowDispatchInput,
+};
 
 use crate::tasks::workflows::{runners::Platform, steps::NamedJob};
 
@@ -132,7 +135,7 @@ impl PathCondition {
 }
 
 pub(crate) struct StepOutput {
-    name: &'static str,
+    pub name: &'static str,
     step_id: String,
 }
 
@@ -151,6 +154,13 @@ impl StepOutput {
     pub fn expr(&self) -> String {
         format!("steps.{}.outputs.{}", self.step_id, self.name)
     }
+
+    pub fn as_job_output(self, job: &NamedJob) -> JobOutput {
+        JobOutput {
+            job_name: job.name.clone(),
+            name: self.name,
+        }
+    }
 }
 
 impl serde::Serialize for StepOutput {
@@ -164,17 +174,43 @@ impl serde::Serialize for StepOutput {
 
 impl std::fmt::Display for StepOutput {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "${{{{ steps.{}.outputs.{} }}}}", self.step_id, self.name)
+        write!(f, "${{{{ {} }}}}", self.expr())
+    }
+}
+
+pub(crate) struct JobOutput {
+    job_name: String,
+    name: &'static str,
+}
+
+impl JobOutput {
+    pub fn expr(&self) -> String {
+        format!("needs.{}.outputs.{}", self.job_name, self.name)
+    }
+}
+
+impl serde::Serialize for JobOutput {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.serialize_str(&self.to_string())
+    }
+}
+
+impl std::fmt::Display for JobOutput {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "${{{{ {} }}}}", self.expr())
     }
 }
 
-pub struct Input {
+pub struct WorkflowInput {
     pub input_type: &'static str,
     pub name: &'static str,
     pub default: Option<String>,
 }
 
-impl Input {
+impl WorkflowInput {
     pub fn string(name: &'static str, default: Option<String>) -> Self {
         Self {
             input_type: "string",
@@ -191,15 +227,62 @@ impl Input {
             default: self.default.clone(),
         }
     }
+
+    pub fn call_input(&self) -> WorkflowCallInput {
+        WorkflowCallInput {
+            description: self.name.to_owned(),
+            required: self.default.is_none(),
+            input_type: self.input_type.to_owned(),
+            default: self.default.clone(),
+        }
+    }
 }
 
-impl std::fmt::Display for Input {
+impl std::fmt::Display for WorkflowInput {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         write!(f, "${{{{ inputs.{} }}}}", self.name)
     }
 }
 
-impl serde::Serialize for Input {
+impl serde::Serialize for WorkflowInput {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.serialize_str(&self.to_string())
+    }
+}
+
+pub(crate) struct WorkflowSecret {
+    pub name: &'static str,
+    description: String,
+    required: bool,
+}
+
+impl WorkflowSecret {
+    pub fn new(name: &'static str, description: impl ToString) -> Self {
+        Self {
+            name,
+            description: description.to_string(),
+            required: true,
+        }
+    }
+
+    pub fn secret_configuration(&self) -> WorkflowCallSecret {
+        WorkflowCallSecret {
+            description: self.description.clone(),
+            required: self.required,
+        }
+    }
+}
+
+impl std::fmt::Display for WorkflowSecret {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "${{{{ secrets.{} }}}}", self.name)
+    }
+}
+
+impl serde::Serialize for WorkflowSecret {
     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
     where
         S: serde::Serializer,