Skip to content

Diagnostic Slack

Steps from Apps was retired and the manifest still lists it

Somebody in operations files a ticket saying the onboarding workflow has stopped creating tickets. You look at the app. It is installed, the token works, the handler is deployed, the tests pass, and the logs contain nothing at all — not an error, not a request, nothing. The workflow itself, opened in Workflow Builder, is not running either.

Nothing broke. On 26 September 2024 Slack retired Steps from Apps, the legacy way an app contributed a custom step to Workflow Builder. Every workflow containing one stopped running that day, the steps stopped working, and the events that would have told your app about it stopped being deliverable. The only trace left is a set of declarations sitting in your live app manifest describing a feature that no longer exists.

Read-only token Python and Node.js Tests included
A grocery store
Photo by Brad on Unsplash
The short answer

Legacy Steps from Apps were retired on 26 September 2024. From that date, workflows containing a legacy step stopped executing, the steps themselves stopped working, and the five associated events — workflow_step_execute, workflow_published, workflow_unpublished, workflow_deleted and workflow_step_deleted — could no longer be subscribed to. There is no automated migration for a legacy step or for the workflows that used one. The replacement is a custom function that Workflow Builder can call, and the workflows themselves have to be rebuilt by hand by the people who own them.

What makes this hard to find is that a retirement is not an error. Nothing returns a failure code, because nothing is called. The handler that would have logged something is simply never reached, and an app whose only Workflow Builder integration was a legacy step looks completely healthy from every angle except the one where somebody's workflow used to run.

The evidence that survives is configuration. A live manifest still carrying features.workflow_steps, retired event names still in settings.event_subscriptions.bot_events, and workflow.steps:execute still in the granted scope list are all dead declarations, and all three are readable. There is no API that will tell you which Workflow Builder workflows referenced your steps or how often they ran, so the manifest is what you get.

The problem in plain words

The awkward part of a retirement is that it produces the same signals as success. A handler that is never invoked writes nothing, so the logs are clean. The app is installed, so auth.test is fine. The scope is granted, so nothing is refused. Your monitoring, which watches for errors, has nothing to report, and would have nothing to report if the feature had never shipped at all.

The people who feel it are not on your team. A Workflow Builder workflow is built by whoever needed it, often in operations or people ops, out of steps offered by apps somebody else installed. When the step disappears, the workflow stops, and the person affected has no reason to connect that to an app, a retirement or a date. The report arrives weeks later as "the onboarding thing does not work any more", and by then nobody remembers that the onboarding thing had a custom step in it.

Meanwhile the configuration stays exactly as it was. Slack did not strip workflow_steps out of anybody's manifest, and it did not remove the granted scope from installed tokens. So an app can carry a complete, well formed declaration of a feature that has not run since 2024, and that declaration will be faithfully reproduced by every export, copied into every new environment, and reviewed by everyone who reads the manifest as though it described something real.

There is a specific trap in the middle of the migration, too. The modern replacement is a custom function, declared under functions, and it is entirely possible to add one while leaving the legacy declaration in place — the two do not conflict, and nothing complains. That state looks like progress and is in fact the worst of both: a manifest that claims two capabilities, one of which cannot run, and a set of users who have no way to tell which of your steps in the Workflow Builder picker is the one that works.

A custom stepshipsused in realworkflows26 September2024Steps from AppsretiredThe workflowsstopand the steps stopwith themNo executeeventthe handler isnever calledDead configremainsin a live manifest
A retirement is not an error. The handler is healthy, the app is installed, the scope is granted, and the event that would have reached the handler is no longer sent by anybody.

Why it happens

This is a configuration read, because there is no traffic read available. Since the retirement there is no method that enumerates which Workflow Builder workflows call your app's steps, how often they ran, or when they last succeeded. That surface is genuinely closed. So the check goes where the evidence still is: the live manifest, the granted scope header, and your own source.

The retired event names are a fixed list, and a fixed list is checkable. Five names stopped being subscribable on one date. Their presence in bot_events is not ambiguous and does not need interpretation: it is configuration describing deliveries that will never happen. Listing them individually rather than as a count matters, because a manifest carrying one of them is a different conversation from a manifest carrying all five.

The scope is the one signal that survives on the token rather than in the document. workflow.steps:execute was only ever granted to apps that implemented legacy steps, so its presence in X-OAuth-Scopes identifies the era of the install regardless of what the manifest currently says. It also survives a manifest edit: clean up the document without reinstalling and the grant stays exactly where it was.

Reading your own source is what separates dead configuration from a dead feature. A manifest declaration with no handler behind it is leftover paperwork and deleting it costs nothing. A manifest declaration with a maintained handler behind it is a capability somebody still believes in, and the repair is a rebuild rather than a deletion. The two need different conversations, so the check reads both and reports the combination rather than either half.

The half migrated state is called out by name because it is the one that looks finished. Legacy steps and modern functions can coexist in one manifest with no error, and a team that shipped the replacement without removing the original will read that as done. It is not: the legacy entry is still in the picker's history, still in the document, and still describing something that cannot run.

Everything here is a read, and the write that would fix it is deliberately not made. apps.manifest.update replaces the whole document, which means a script that "just removes the dead entries" is a script that rewrites your app configuration from a copy it made a moment ago. This one prints what to delete and leaves the deleting to you.

The fix, as a flow

This is the only chain in the batch with a date in it, and the date is the mechanism. Slack retired Steps from Apps on 26 September 2024: the workflows containing them stopped running, the steps stopped working, and the events stopped being subscribable. Nothing in your app changed, and nothing in your app reports it, because the handler that would have logged something is simply never called. The red arrow is the first one, before any request exists. The fix branch sorts a manifest rather than traffic, because dead configuration is the only trace the retirement left behind and it is sitting in a document you can export.

Manifest, grant, sourcewhat the retirement left standingworkflow_steps declareda feature that no longer runsRetired events listedfive names nobody can sendworkflow.steps:executea scope only legacy apps holdLegacy and modern bothhalf migrated, both declaredCustom functions onlyrebuilt on the current model
Three readings, none of which needs traffic: what the manifest still declares, which retired events it still lists, and whether the grant carries a scope only legacy step apps were ever given.

How to fix it

Export the live manifest, not the one in the repository

apps.manifest.export with an app configuration token returns what Slack is actually running. The repository copy is what you think you shipped, and on this particular feature the two diverge routinely, because the legacy declaration predates whenever the manifest was first checked in.

Look for the legacy feature block

features.workflow_steps is the declaration. Each entry has a name and a callback_id, and every one of them is a step that has not been callable since 26 September 2024. The callback ids are worth printing: they are what your handler registrations are keyed on.

List the retired events by name

retired_events takes settings.event_subscriptions.bot_events and returns the subset that stopped being subscribable. One of the five is a leftover; all five is an app that was built around this feature.

Read the granted scopes, which the manifest does not cover

workflow.steps:execute in X-OAuth-Scopes means the installed token still carries the legacy grant. Cleaning the manifest does not remove it, because a grant only moves when somebody reinstalls, so this is the line that tells you whether the cleanup was finished or only started.

Check whether a handler still exists behind the declaration

source_findings looks for the Bolt symbols — WorkflowStep, workflow_step_execute, a .step( registration — in the source you point it at. Dead paperwork gets deleted; a maintained handler gets rebuilt, and telling the two apart is the point.

Rebuild as a custom function, and tell the workflow owners

The modern replacement is a custom function that Workflow Builder can call, declared under functions in the manifest. Slack provides no automated migration for either the step or the workflows that used it, so the second half of the repair is not code: it is telling the people whose workflows stopped that they have to rebuild them, which nobody will discover on their own.

How to check it worked

Once the legacy declarations are gone and the app has been reinstalled, run it again. The manifest rows should be empty and the scope row should say the legacy grant is gone.

python3 slack_legacy_workflow_steps.py --app-id A05NW7XQ1 --source ./listeners
# manifest   ok             exported A05NW7XQ1
# manifest   legacy-steps   2 legacy step(s) declared: create_ticket, notify_manager
# manifest   retired-event  workflow_step_execute is subscribed and cannot be delivered
# manifest   retired-event  workflow_step_deleted is subscribed and cannot be delivered
# manifest   no-modern-functions  nothing under functions, so no replacement exists yet
# scopes     legacy-scope   workflow.steps:execute is granted; only legacy step apps
#                           ever held it, and it survives a manifest edit
# source     legacy-handler WorkflowStep appears in ./listeners/onboarding.py
# state      not-started    the legacy feature is declared, granted and still handled
# verdict    6 finding(s)
#   repair: rebuild each step as a custom function Workflow Builder can call
#   repair: tell the workflow owners; Slack provides no automated migration
#   note:   apps.manifest.update would replace the whole document and is not called

The full code

Two reads, both GET, and the one method that would fix this is deliberately absent. retired_events holds the five names against your subscription list; manifest_findings reads the legacy feature block, those events and whether a modern replacement exists yet; scope_findings reads the one grant only legacy step apps were ever given, which lives on the token rather than in the document; source_findings looks for the handler symbols in the source you point it at; and migration_state combines the three into the sentence you actually want, which is whether this is leftover paperwork, a half migration, or a feature somebody still maintains.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_legacy_workflow_steps.py
"""Find legacy Steps from Apps configuration left standing after the retirement.

Slack retired legacy Steps from Apps on 26 September 2024. Workflows containing
one stopped running, the steps stopped working, and the five associated events
stopped being subscribable. Nothing about that produced an error, because
nothing was called - which is why the evidence is configuration rather than
traffic. There is no method that enumerates which Workflow Builder workflows
referenced your steps or how often they ran; that surface is closed.

Read only. Two GETs: apps.manifest.export with an app configuration token, and
auth.test with any bot token so the granted scope header can be read. The
method that would fix this, apps.manifest.update, replaces the whole document
from a copy made a moment earlier, and is never called here.
"""
import argparse
import logging
import os
import sys
from pathlib import Path

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_legacy_workflow_steps")

API = "https://slack.com/api/"

RETIREMENT_DATE = "26 September 2024"

# The five events that stopped being subscribable on the retirement date. A
# fixed list, which is what makes this checkable rather than interpretive.
RETIRED_EVENTS = ("workflow_step_execute", "workflow_published",
                  "workflow_unpublished", "workflow_deleted",
                  "workflow_step_deleted")

# Granted only to apps that implemented legacy steps, and it lives on the
# installed token rather than in the manifest, so it survives a document edit.
LEGACY_SCOPE = "workflow.steps:execute"

# Symbols that mean a handler still exists behind the declaration. Deliberately
# few: this is a signal, not a parser, and a false positive costs one look.
LEGACY_SYMBOLS = (
    ("WorkflowStep", "the Bolt legacy step class"),
    ("workflow_step_execute", "a handler for the retired execute event"),
    (".step(", "a Bolt legacy step registration"),
    (LEGACY_SCOPE, "the legacy scope requested in code"),
)


def retired_events(bot_events):
    """Which subscribed events can no longer be delivered? Pure.

    Returned in the order of RETIRED_EVENTS rather than the order of the
    manifest, so two exports of the same app read the same way.
    """
    subscribed = {str(e) for e in (bot_events or [])}
    return [e for e in RETIRED_EVENTS if e in subscribed]


def manifest_findings(manifest):
    """Read a live manifest for configuration the retirement left behind. Pure.

    Returns [(code, why), ...]. Nothing here is an error Slack will ever
    report: it is a document describing a feature that stopped existing.
    """
    doc = (manifest or {}).get("manifest", manifest) or {}
    features = doc.get("features") or {}
    settings = doc.get("settings") or {}
    steps = features.get("workflow_steps") or []
    out = []
    if steps:
        names = [str((s or {}).get("callback_id") or (s or {}).get("name") or "unnamed")
                 for s in steps]
        out.append(("legacy-steps",
                    "%d legacy step(s) declared: %s. None has been callable since %s"
                    % (len(steps), ", ".join(names), RETIREMENT_DATE)))
    bot_events = ((settings.get("event_subscriptions") or {}).get("bot_events")) or []
    for event in retired_events(bot_events):
        out.append(("retired-event",
                    "%s is subscribed and cannot be delivered; it stopped being "
                    "subscribable on %s" % (event, RETIREMENT_DATE)))
    if steps and not (doc.get("functions") or {}):
        out.append(("no-modern-functions",
                    "nothing is declared under functions, so no replacement exists "
                    "yet and the capability is simply gone"))
    return out


def scope_findings(scope_header):
    """Read the granted scope list for the legacy grant. Pure.

    X-OAuth-Scopes is what the installed token actually holds, which is a
    different question from what the manifest asks for. A manifest cleaned up
    without a reinstall leaves this exactly where it was.
    """
    granted = [s.strip() for s in str(scope_header or "").split(",") if s.strip()]
    if LEGACY_SCOPE in granted:
        return [("legacy-scope",
                 "%s is granted on the installed token; only legacy step apps ever "
                 "held it, and it survives a manifest edit because a grant moves only "
                 "when somebody reinstalls" % LEGACY_SCOPE)]
    return []


def source_findings(text, label="source"):
    """Is there still a handler behind the declaration? Pure.

    A declaration with no handler is leftover paperwork and deleting it costs
    nothing. A declaration with a maintained handler is a capability somebody
    still believes in, and the repair is a rebuild. Different conversations,
    so the check reads both halves.
    """
    body = str(text or "")
    return [("legacy-handler", "%s appears in %s" % (symbol, label))
            for symbol, _why in LEGACY_SYMBOLS if symbol in body]


def migration_state(has_legacy_manifest, has_retired_events, has_legacy_scope,
                    has_modern_functions, has_legacy_source):
    """Say in one word where this app actually stands. Pure.

    clean         nothing legacy anywhere.
    migrated      a modern function exists and no legacy trace remains.
    half-migrated the replacement was built and the legacy entry was left in
                  place. This is the state that looks finished and is not.
    source-only   a handler survives with nothing declared. Dead code that no
                  user can reach and no reviewer will question.
    dead-config   declarations and grants with no handler behind them. Delete.
    not-started   declared, granted and still maintained, with no replacement.
    """
    legacy_config = bool(has_legacy_manifest or has_retired_events or has_legacy_scope)
    if not legacy_config and not has_legacy_source:
        return ("migrated" if has_modern_functions else "clean",
                "no legacy Steps from Apps configuration remains")
    if legacy_config and has_modern_functions:
        return ("half-migrated",
                "a modern function is declared and the legacy step was left beside "
                "it; both appear to users and only one can run")
    if not legacy_config and has_legacy_source:
        return ("source-only",
                "a legacy handler survives in source with nothing declared, so it is "
                "dead code no user can reach")
    if legacy_config and not has_legacy_source:
        return ("dead-config",
                "legacy declarations and grants remain with no handler behind them, "
                "which is paperwork rather than a capability")
    return ("not-started",
            "the legacy feature is declared, granted and still handled, and no "
            "replacement exists")


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--app-id", default="", help="the app id to export, A...")
    ap.add_argument("--config-token-env", default="SLACK_CONFIG_ACCESS_TOKEN",
                    help="environment variable holding an app configuration token")
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding any bot token, for the scopes")
    ap.add_argument("--granted", default="",
                    help="comma separated granted scopes, if you have no bot token")
    ap.add_argument("--source", default="",
                    help="a directory of source files to read for handler symbols")
    args = ap.parse_args()

    findings = 0
    session = requests.Session()
    manifest = {}
    config_token = os.environ.get(args.config_token_env)
    if args.app_id and config_token:
        r = session.get(API + "apps.manifest.export",
                        headers={"Authorization": "Bearer " + config_token},
                        params={"app_id": args.app_id}, timeout=30)
        try:
            body = r.json()
        except ValueError:
            body = {"ok": False, "error": "unparseable_body"}
        if body.get("ok") is True:
            manifest = body
            log.info("manifest   ok             exported %s", args.app_id)
        else:
            log.warning("manifest   unavailable    apps.manifest.export answered "
                        "ok: false, error=%s", body.get("error"))
    else:
        log.info("manifest   skipped        set %s and pass --app-id to read the live "
                 "manifest", args.config_token_env)

    manifest_hits = manifest_findings(manifest)
    for code, why in manifest_hits:
        log.warning("manifest   %-14s %s", code, why)
        findings += 1

    granted = args.granted
    if not granted:
        token = os.environ.get(args.token_env)
        if token:
            reply = session.get(API + "auth.test",
                                headers={"Authorization": "Bearer " + token},
                                timeout=30)
            granted = reply.headers.get("x-oauth-scopes") or ""
        else:
            log.info("scopes     skipped        set %s or pass --granted to read the "
                     "grant on the installed token", args.token_env)
    scope_hits = scope_findings(granted)
    for code, why in scope_hits:
        log.warning("scopes     %-14s %s", code, why)
        findings += 1

    source_hits = []
    if args.source:
        for path in sorted(Path(args.source).rglob("*")):
            if not path.is_file() or path.suffix not in (".py", ".js", ".mjs", ".ts"):
                continue
            hits = source_findings(path.read_text(encoding="utf-8", errors="replace"),
                                   str(path))
            source_hits.extend(hits)
        for code, why in source_hits:
            log.warning("source     %-14s %s", code, why)
    else:
        log.info("source     skipped        pass --source to tell dead paperwork from "
                 "a capability somebody still maintains")

    doc = (manifest or {}).get("manifest", manifest) or {}
    state, why = migration_state(
        bool(((doc.get("features") or {}).get("workflow_steps"))),
        bool([c for c, _w in manifest_hits if c == "retired-event"]),
        bool(scope_hits),
        bool(doc.get("functions") or {}),
        bool(source_hits))
    (log.info if state in ("clean", "migrated") else log.warning)(
        "state      %-14s %s", state, why)

    if not findings and state in ("clean", "migrated"):
        log.info("verdict    clean          nothing from the retired surface remains")
        return 0
    log.warning("verdict    %d finding(s)", findings)
    log.warning("  repair: rebuild each step as a custom function that Workflow "
                "Builder can call, declared under functions in the manifest")
    log.warning("  repair: remove the workflow_steps block and the retired event "
                "subscriptions, then reinstall so the legacy grant goes with them")
    log.warning("  repair: tell the workflow owners their workflows must be rebuilt; "
                "Slack provides no automated migration for either half")
    log.warning("  note:   apps.manifest.update would replace the whole document from "
                "a copy made a moment ago, and is not called here")
    return 1


if __name__ == "__main__":
    sys.exit(main())
slack-legacy-workflow-steps.mjs
/**
 * Find legacy Steps from Apps configuration left standing after the retirement.
 *
 * Slack retired legacy Steps from Apps on 26 September 2024. Workflows
 * containing one stopped running, the steps stopped working, and the five
 * associated events stopped being subscribable. None of that produced an
 * error, because nothing was called - which is why the evidence is
 * configuration rather than traffic. There is no method that enumerates which
 * Workflow Builder workflows referenced your steps; that surface is closed.
 *
 * Read only. Two GETs: apps.manifest.export with an app configuration token,
 * and auth.test with any bot token for the granted scope header. The method
 * that would fix this, apps.manifest.update, replaces the whole document and
 * is never called here.
 */
import { readdir, readFile } from 'node:fs/promises';
import { join, extname } from 'node:path';

const API = 'https://slack.com/api/';

export const RETIREMENT_DATE = '26 September 2024';

// The five events that stopped being subscribable on the retirement date.
export const RETIRED_EVENTS = ['workflow_step_execute', 'workflow_published',
  'workflow_unpublished', 'workflow_deleted', 'workflow_step_deleted'];

// Granted only to apps that implemented legacy steps, and it lives on the
// installed token rather than in the manifest.
export const LEGACY_SCOPE = 'workflow.steps:execute';

// Symbols that mean a handler still exists behind the declaration.
export const LEGACY_SYMBOLS = ['WorkflowStep', 'workflow_step_execute', '.step(',
  LEGACY_SCOPE];

/** Which subscribed events can no longer be delivered? Pure. */
export function retiredEvents(botEvents) {
  const subscribed = new Set((botEvents ?? []).map(String));
  return RETIRED_EVENTS.filter((e) => subscribed.has(e));
}

/** Read a live manifest for configuration the retirement left behind. Pure. */
export function manifestFindings(manifest) {
  const doc = manifest?.manifest ?? manifest ?? {};
  const steps = doc.features?.workflow_steps ?? [];
  const out = [];
  if (steps.length) {
    const names = steps.map((s) => String(s?.callback_id ?? s?.name ?? 'unnamed'));
    out.push(['legacy-steps', `${steps.length} legacy step(s) declared: `
      + `${names.join(', ')}. None has been callable since ${RETIREMENT_DATE}`]);
  }
  const botEvents = doc.settings?.event_subscriptions?.bot_events ?? [];
  for (const event of retiredEvents(botEvents)) {
    out.push(['retired-event', `${event} is subscribed and cannot be delivered; it `
      + `stopped being subscribable on ${RETIREMENT_DATE}`]);
  }
  if (steps.length && !Object.keys(doc.functions ?? {}).length) {
    out.push(['no-modern-functions', 'nothing is declared under functions, so no '
      + 'replacement exists yet and the capability is simply gone']);
  }
  return out;
}

/** Read the granted scope list for the legacy grant. Pure. */
export function scopeFindings(scopeHeader) {
  const granted = String(scopeHeader ?? '').split(',')
    .map((s) => s.trim()).filter(Boolean);
  if (granted.includes(LEGACY_SCOPE)) {
    return [['legacy-scope', `${LEGACY_SCOPE} is granted on the installed token; only `
      + 'legacy step apps ever held it, and it survives a manifest edit because a '
      + 'grant moves only when somebody reinstalls']];
  }
  return [];
}

/** Is there still a handler behind the declaration? Pure. */
export function sourceFindings(text, label = 'source') {
  const body = String(text ?? '');
  return LEGACY_SYMBOLS.filter((symbol) => body.includes(symbol))
    .map((symbol) => ['legacy-handler', `${symbol} appears in ${label}`]);
}

/**
 * Say in one word where this app actually stands. Pure.
 * clean, migrated, half-migrated, source-only, dead-config, not-started.
 */
export function migrationState(hasLegacyManifest, hasRetiredEvents, hasLegacyScope,
  hasModernFunctions, hasLegacySource) {
  const legacyConfig = Boolean(hasLegacyManifest || hasRetiredEvents || hasLegacyScope);
  if (!legacyConfig && !hasLegacySource) {
    return [hasModernFunctions ? 'migrated' : 'clean',
      'no legacy Steps from Apps configuration remains'];
  }
  if (legacyConfig && hasModernFunctions) {
    return ['half-migrated', 'a modern function is declared and the legacy step was '
      + 'left beside it; both appear to users and only one can run'];
  }
  if (!legacyConfig && hasLegacySource) {
    return ['source-only', 'a legacy handler survives in source with nothing declared, '
      + 'so it is dead code no user can reach'];
  }
  if (legacyConfig && !hasLegacySource) {
    return ['dead-config', 'legacy declarations and grants remain with no handler '
      + 'behind them, which is paperwork rather than a capability'];
  }
  return ['not-started', 'the legacy feature is declared, granted and still handled, '
    + 'and no replacement exists'];
}

function arg(args, name, fallback = '') {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

async function sourceFiles(dir) {
  const out = [];
  for (const entry of await readdir(dir, { withFileTypes: true })) {
    const full = join(dir, entry.name);
    if (entry.isDirectory()) out.push(...await sourceFiles(full));
    else if (['.py', '.js', '.mjs', '.ts'].includes(extname(entry.name))) out.push(full);
  }
  return out.sort();
}

async function main() {
  const args = process.argv.slice(2);
  const appId = arg(args, '--app-id');
  const configTokenEnv = arg(args, '--config-token-env', 'SLACK_CONFIG_ACCESS_TOKEN');
  const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
  const source = arg(args, '--source');
  let findings = 0;
  let manifest = {};

  const configToken = process.env[configTokenEnv];
  if (appId && configToken) {
    const r = await fetch(`${API}apps.manifest.export?app_id=${encodeURIComponent(appId)}`,
      { headers: { Authorization: `Bearer ${configToken}` } });
    let body;
    try {
      body = await r.json();
    } catch {
      body = { ok: false, error: 'unparseable_body' };
    }
    if (body.ok === true) {
      manifest = body;
      console.log(`manifest   ok             exported ${appId}`);
    } else {
      console.warn('manifest   unavailable    apps.manifest.export answered ok: false, '
        + `error=${body.error}`);
    }
  } else {
    console.log(`manifest   skipped        set ${configTokenEnv} and pass --app-id to `
      + 'read the live manifest');
  }

  const manifestHits = manifestFindings(manifest);
  for (const [code, why] of manifestHits) {
    console.warn(`manifest   ${code.padEnd(14)} ${why}`);
    findings += 1;
  }

  let granted = arg(args, '--granted');
  if (!granted) {
    const token = process.env[tokenEnv];
    if (token) {
      const reply = await fetch(`${API}auth.test`,
        { headers: { Authorization: `Bearer ${token}` } });
      granted = reply.headers.get('x-oauth-scopes') ?? '';
    } else {
      console.log(`scopes     skipped        set ${tokenEnv} or pass --granted to read `
        + 'the grant on the installed token');
    }
  }
  const scopeHits = scopeFindings(granted);
  for (const [code, why] of scopeHits) {
    console.warn(`scopes     ${code.padEnd(14)} ${why}`);
    findings += 1;
  }

  const sourceHits = [];
  if (source) {
    for (const path of await sourceFiles(source)) {
      sourceHits.push(...sourceFindings(await readFile(path, 'utf8'), path));
    }
    for (const [code, why] of sourceHits) {
      console.warn(`source     ${code.padEnd(14)} ${why}`);
    }
  } else {
    console.log('source     skipped        pass --source to tell dead paperwork from a '
      + 'capability somebody still maintains');
  }

  const doc = manifest?.manifest ?? manifest ?? {};
  const [state, why] = migrationState(
    Boolean(doc.features?.workflow_steps?.length),
    manifestHits.some(([c]) => c === 'retired-event'),
    Boolean(scopeHits.length),
    Boolean(Object.keys(doc.functions ?? {}).length),
    Boolean(sourceHits.length));
  ((state === 'clean' || state === 'migrated') ? console.log : console.warn)(
    `state      ${state.padEnd(14)} ${why}`);

  if (!findings && (state === 'clean' || state === 'migrated')) {
    console.log('verdict    clean          nothing from the retired surface remains');
    return;
  }
  console.warn(`verdict    ${findings} finding(s)`);
  console.warn('  repair: rebuild each step as a custom function that Workflow Builder '
    + 'can call, declared under functions in the manifest');
  console.warn('  repair: remove the workflow_steps block and the retired event '
    + 'subscriptions, then reinstall so the legacy grant goes with them');
  console.warn('  repair: tell the workflow owners their workflows must be rebuilt; '
    + 'Slack provides no automated migration for either half');
  console.warn('  note:   apps.manifest.update would replace the whole document from a '
    + 'copy made a moment ago, and is not called here');
  process.exitCode = 1;
}

// Only run when invoked directly, so importing this module in the tests does
// not execute main().
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

There is no token in these fixtures and no fixture that needs one, because the whole check is a document and a comma separated header. The assertions worth reading are the state machine ones: half-migrated has to win over dead-config when both a legacy step and a modern function are declared, because that is the state a team will otherwise report as finished, and source-only has to be distinguishable from clean, because a handler nobody can reach is dead code rather than a tidy app. The retired event list is asserted in its declared order so that two exports of the same app read the same way.

test_slack_legacy_workflow_steps.py
from slack_legacy_workflow_steps import (
    manifest_findings, migration_state, retired_events, scope_findings,
    source_findings,
)

LEGACY = {"manifest": {
    "features": {"workflow_steps": [
        {"name": "Create ticket", "callback_id": "create_ticket"},
        {"name": "Notify manager", "callback_id": "notify_manager"},
    ]},
    "settings": {"event_subscriptions": {
        "bot_events": ["workflow_step_execute", "app_mention", "workflow_step_deleted"],
    }},
}}

MODERN = {"manifest": {
    "features": {},
    "functions": {"create_ticket": {"title": "Create ticket"}},
    "settings": {"event_subscriptions": {"bot_events": ["app_mention"]}},
}}


def codes(hits):
    return [c for c, _w in hits]


def test_only_the_retired_events_are_returned_and_in_a_fixed_order():
    found = retired_events(["workflow_step_deleted", "app_mention",
                            "workflow_step_execute"])
    assert found == ["workflow_step_execute", "workflow_step_deleted"]


def test_an_empty_or_missing_subscription_list_returns_nothing():
    assert retired_events([]) == []
    assert retired_events(None) == []


def test_the_legacy_feature_block_is_reported_with_its_callback_ids():
    hits = manifest_findings(LEGACY)
    legacy = [w for c, w in hits if c == "legacy-steps"][0]
    assert "create_ticket" in legacy and "notify_manager" in legacy
    assert "26 September 2024" in legacy


def test_each_retired_subscription_is_reported_on_its_own_line():
    assert codes(manifest_findings(LEGACY)).count("retired-event") == 2


def test_a_legacy_declaration_with_no_replacement_says_so():
    assert "no-modern-functions" in codes(manifest_findings(LEGACY))


def test_a_modern_manifest_has_nothing_to_report():
    assert manifest_findings(MODERN) == []
    assert manifest_findings({}) == []


def test_a_manifest_returned_unwrapped_is_read_the_same_way():
    assert codes(manifest_findings(LEGACY["manifest"])) == codes(
        manifest_findings(LEGACY))


def test_the_legacy_scope_is_found_in_the_granted_header():
    hits = scope_findings("chat:write,workflow.steps:execute, channels:read")
    assert codes(hits) == ["legacy-scope"]
    assert "reinstall" in hits[0][1]


def test_a_modern_grant_carries_nothing_to_report():
    assert scope_findings("chat:write,channels:read") == []
    assert scope_findings("") == []


def test_the_handler_symbols_are_found_in_source():
    hits = source_findings("from slack_bolt.workflows.step import WorkflowStep",
                           "listeners/onboarding.py")
    assert codes(hits) == ["legacy-handler"]
    assert "listeners/onboarding.py" in hits[0][1]


def test_source_with_no_legacy_symbol_reports_nothing():
    assert source_findings("app.event('app_mention')(handle)") == []


def test_an_app_with_nothing_legacy_anywhere_is_clean():
    assert migration_state(False, False, False, False, False)[0] == "clean"


def test_a_rebuilt_app_with_no_legacy_trace_is_migrated():
    assert migration_state(False, False, False, True, False)[0] == "migrated"


def test_the_replacement_beside_the_original_is_the_state_that_looks_finished():
    state, why = migration_state(True, True, True, True, True)
    assert state == "half-migrated"
    assert "only one can run" in why


def test_declarations_with_no_handler_behind_them_are_paperwork():
    assert migration_state(True, True, True, False, False)[0] == "dead-config"


def test_a_handler_with_nothing_declared_is_dead_code_rather_than_clean():
    state, why = migration_state(False, False, False, False, True)
    assert state == "source-only"
    assert "no user can reach" in why


def test_declared_granted_and_maintained_with_no_replacement_is_not_started():
    assert migration_state(True, True, True, False, True)[0] == "not-started"


def test_the_scope_alone_is_enough_to_count_as_legacy_configuration():
    assert migration_state(False, False, True, False, False)[0] == "dead-config"
slack-legacy-workflow-steps.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  manifestFindings, migrationState, retiredEvents, scopeFindings, sourceFindings,
} from './slack-legacy-workflow-steps.mjs';

const LEGACY = {
  manifest: {
    features: {
      workflow_steps: [
        { name: 'Create ticket', callback_id: 'create_ticket' },
        { name: 'Notify manager', callback_id: 'notify_manager' },
      ],
    },
    settings: {
      event_subscriptions: {
        bot_events: ['workflow_step_execute', 'app_mention', 'workflow_step_deleted'],
      },
    },
  },
};

const MODERN = {
  manifest: {
    features: {},
    functions: { create_ticket: { title: 'Create ticket' } },
    settings: { event_subscriptions: { bot_events: ['app_mention'] } },
  },
};

const codes = (hits) => hits.map(([c]) => c);

test('only the retired events are returned and in a fixed order', () => {
  assert.deepEqual(
    retiredEvents(['workflow_step_deleted', 'app_mention', 'workflow_step_execute']),
    ['workflow_step_execute', 'workflow_step_deleted']);
});

test('an empty or missing subscription list returns nothing', () => {
  assert.deepEqual(retiredEvents([]), []);
  assert.deepEqual(retiredEvents(null), []);
});

test('the legacy feature block is reported with its callback ids', () => {
  const legacy = manifestFindings(LEGACY).find(([c]) => c === 'legacy-steps')[1];
  assert.equal(legacy.includes('create_ticket'), true);
  assert.equal(legacy.includes('notify_manager'), true);
  assert.equal(legacy.includes('26 September 2024'), true);
});

test('each retired subscription is reported on its own line', () => {
  assert.equal(codes(manifestFindings(LEGACY))
    .filter((c) => c === 'retired-event').length, 2);
});

test('a legacy declaration with no replacement says so', () => {
  assert.equal(codes(manifestFindings(LEGACY)).includes('no-modern-functions'), true);
});

test('a modern manifest has nothing to report', () => {
  assert.deepEqual(manifestFindings(MODERN), []);
  assert.deepEqual(manifestFindings({}), []);
});

test('a manifest returned unwrapped is read the same way', () => {
  assert.deepEqual(codes(manifestFindings(LEGACY.manifest)),
    codes(manifestFindings(LEGACY)));
});

test('the legacy scope is found in the granted header', () => {
  const hits = scopeFindings('chat:write,workflow.steps:execute, channels:read');
  assert.deepEqual(codes(hits), ['legacy-scope']);
  assert.equal(hits[0][1].includes('reinstall'), true);
});

test('a modern grant carries nothing to report', () => {
  assert.deepEqual(scopeFindings('chat:write,channels:read'), []);
  assert.deepEqual(scopeFindings(''), []);
});

test('the handler symbols are found in source', () => {
  const hits = sourceFindings("import { WorkflowStep } from '@slack/bolt';",
    'listeners/onboarding.js');
  assert.deepEqual(codes(hits), ['legacy-handler']);
  assert.equal(hits[0][1].includes('listeners/onboarding.js'), true);
});

test('source with no legacy symbol reports nothing', () => {
  assert.deepEqual(sourceFindings("app.event('app_mention', handle);"), []);
});

test('an app with nothing legacy anywhere is clean', () => {
  assert.equal(migrationState(false, false, false, false, false)[0], 'clean');
});

test('a rebuilt app with no legacy trace is migrated', () => {
  assert.equal(migrationState(false, false, false, true, false)[0], 'migrated');
});

test('the replacement beside the original is the state that looks finished', () => {
  const [state, why] = migrationState(true, true, true, true, true);
  assert.equal(state, 'half-migrated');
  assert.equal(why.includes('only one can run'), true);
});

test('declarations with no handler behind them are paperwork', () => {
  assert.equal(migrationState(true, true, true, false, false)[0], 'dead-config');
});

test('a handler with nothing declared is dead code rather than clean', () => {
  const [state, why] = migrationState(false, false, false, false, true);
  assert.equal(state, 'source-only');
  assert.equal(why.includes('no user can reach'), true);
});

test('declared, granted and maintained with no replacement is not started', () => {
  assert.equal(migrationState(true, true, true, false, true)[0], 'not-started');
});

test('the scope alone is enough to count as legacy configuration', () => {
  assert.equal(migrationState(false, false, true, false, false)[0], 'dead-config');
});

FAQ

When exactly did this stop working, and what stopped?

26 September 2024. From that date, Workflow Builder workflows containing a legacy step stopped executing, the steps themselves stopped working, and five events stopped being subscribable: workflow_step_execute, workflow_published, workflow_unpublished, workflow_deleted and workflow_step_deleted. Nothing was deleted from anybody's app configuration, which is why a manifest exported today can still describe the feature in full.

Is there a migration path from a legacy step to a modern one?

Not an automated one, for either half. The replacement is a custom function that Workflow Builder can call, and you write it. The workflows that used the old step cannot be converted either: they have to be rebuilt by the people who own them, in Workflow Builder, by hand. That second half is the part that gets forgotten, because it is not your work and the people who have to do it will not find out on their own.

Can I find out which workflows were using my steps?

No, and this is a real blind spot rather than a gap in the script. Since the retirement there is no read API that enumerates Workflow Builder workflows referencing an app's steps, or how often they ran, or when they last succeeded. The manifest tells you what your app declared; nothing tells you who was using it. In practice the list has to be reconstructed by asking, which is another reason to send the notice early.

We removed the workflow_steps block. Why does the scope still show up?

Because a granted scope lives on the installed token, not in the manifest, and it moves only when somebody reinstalls the app. Editing the document changes what would be requested next time; it changes nothing about what is currently held. That is exactly why the check reads X-OAuth-Scopes separately, and why workflow.steps:execute is the line that tells you whether the cleanup was finished or only started.

Our manifest has both a legacy step and a new custom function. Is that fine?

It is the half-migrated state, and it is the one worth acting on soonest, because it looks finished. Nothing conflicts and nothing errors, so the manifest quietly claims two capabilities of which one cannot run. Anybody browsing your app's steps has no way to tell which is which, and the legacy entry will keep being copied into every environment created from that document. Delete it, then reinstall so the grant goes with it.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.